From 3f2ccfeabb718c7c3ccef5648f5d7e9540e55efa Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Fri, 31 Jul 2026 23:00:01 +1000 Subject: [PATCH 01/17] docs: add project notes and code review --- docs/README.md | 8 + docs/code-deep-dive.md | 197 ++++++++++++++++ docs/garbro.md | 421 +++++++++++++++++++++++++++++++++ docs/quickbms.md | 518 +++++++++++++++++++++++++++++++++++++++++ docs/unity.md | 318 +++++++++++++++++++++++++ 5 files changed, 1462 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/code-deep-dive.md create mode 100644 docs/garbro.md create mode 100644 docs/quickbms.md create mode 100644 docs/unity.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..498bf9e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Project documentation + +- [Current code deep dive](code-deep-dive.md) — architecture, format implementations, defects, and technical risks found during the static review. +- [GARbro module plan](garbro.md) +- [QuickBMS module plan](quickbms.md) +- [Unity module plan](unity.md) + +The repository-level [`README.md`](../README.md) remains the public project entry point. Agent instructions remain in [`CLAUDE.md`](../CLAUDE.md). diff --git a/docs/code-deep-dive.md b/docs/code-deep-dive.md new file mode 100644 index 0000000..f341d1d --- /dev/null +++ b/docs/code-deep-dive.md @@ -0,0 +1,197 @@ +# Current Code Deep Dive + +Static review of the current ObserverModules implementation. The project was not built and the tests were not run during this review. + +## Scope + +The review covered: + +- the Observer ABI declarations and exported entry points; +- the common archive wrapper and extraction lifecycle; +- the Ren'Py, RPG Maker, and Zanzarah format implementations; +- the custom Ren'Py Pickle parser; +- the test harness and all current test cases; +- the history of the main format-related changes. + +## Architecture + +```text +FAR Manager + -> Observer + -> renpy.so + -> rpgmaker.so + -> zanzarah.so + -> dll.cpp Observer C ABI and HANDLE lifecycle + -> archive.cpp shared listing and extraction pipeline + -> format.cpp format-specific parser and decryption +``` + +There are two distinct plugin boundaries: + +1. Observer dynamically loads each Windows DLL and obtains its function table through `LoadSubModule()`. +2. Inside a module, the archive format is selected statically at link time. Each CMake target links the common `dll.cpp` and `archive.cpp` with one implementation of the non-virtual `extractor::extractor` methods. + +Despite its name, `extractor::extractor` is not a runtime-polymorphic format interface. Its destructor is virtual, but `get_archive_info()`, `list_files()`, and `decrypt()` are not. This distinction matters when evolving the internal module architecture. + +### Storage lifecycle + +1. `OpenStorage()` validates the supplied signature when present, opens an `ifstream`, and returns an `archive::archive*` cast to `HANDLE`. +2. `PrepareFiles()` parses and caches the archive index. +3. `GetItem()` exposes paths and sizes to Observer. +4. `ExtractItem()` copies a selected entry in 128 KiB chunks and invokes the progress callback. +5. `CloseStorage()` reconstructs a `unique_ptr` from the `HANDLE` and destroys the archive. + +The common archive layer is a useful separation: format implementations provide indexing and block transformation while file I/O and the Observer-facing lifecycle remain shared. + +## Format implementations + +### Ren'Py + +- Recognizes the `RPA-` signature and versions 2.0 and 3.0. +- Reads the compressed index at the offset stored in the archive header. +- RPA-3 offsets and lengths are decoded with the header key. +- Decompresses the whole index into memory with zlib/zstr. +- Parses the index with the local Pickle subset. +- Supports the optional per-entry prefix/header and excludes its length from the body copied from the archive. + +The implementation only accepts one tuple segment per archive path. A valid index containing multiple segments reaches the explicit `Not implemented` branch in [`renpy.cpp`](../src/modules/renpy/renpy.cpp#L101). + +### RPG Maker VX Ace + +- Recognizes `RGSSAD\0\3`. +- Decodes index fields and file names using the archive magic. +- Stores a separate initial magic value for each file. +- Decrypts file bodies as a rolling 32-bit XOR stream. + +The shared extraction buffer is divisible by four, which is important for preserving the rolling-magic state between chunks. + +### Zanzarah + +- Recognizes four zero bytes. +- Reads the file count followed by path, relative offset, and block size records. +- Treats data offsets as relative to the end of the index. +- Skips the four-byte per-block attribute and removes one leading `..\` from entry paths. + +## Confirmed defects + +### 1. Cancellation is reported as success + +The progress adapter throws `archive::user_interrupt` when Observer requests cancellation in [`dll.cpp`](../src/dll.cpp#L110). However, [`archive::extract_file()`](../src/archive.cpp#L111) catches that exception and returns normally. + +Consequences: + +- the `SER_USERABORT` handler in `ExtractItem()` is unreachable for this path; +- Observer receives `SER_SUCCESS`; +- a partially extracted file remains at the destination. + +The exception should reach the ABI adapter, or cancellation should be returned explicitly through the internal API. + +### 2. Zanzarah uses vector capacity as the file count + +[`zanzarah.cpp`](../src/modules/zanzarah/zanzarah.cpp#L59) calls `reserve(file_count)` and then iterates until `files.capacity()`. + +The C++ standard only guarantees that capacity is at least the requested count. If an implementation allocates additional capacity, the parser reads records beyond the archive index. The decoded count must be stored separately and used as the loop bound. + +### 3. Small archives break the test harness + +The test adapter enables `failbit` exceptions, allocates a 128 KiB signature buffer, and unconditionally requests the entire buffer in [`observer.cpp`](../src/tests/framework/observer.cpp#L54). `ifstream::read()` sets `failbit` when a valid archive is shorter than 128 KiB, so `gcount()` is never used normally for such a file. + +The harness should read up to the available length without treating a short final read as a test failure. + +## Robustness and security risks + +### Untrusted lengths and offsets + +The parsers use archive-controlled values for allocations and stream positions without consistently checking them against the physical file size: + +- Zanzarah file count, `path_len`, block offset, and block size; +- RPG Maker `name_len`, file offset, and file size; +- Ren'Py index offset, decoded entry offset and size, and decompressed index size. + +A malformed archive can therefore cause excessive allocation, negative logical sizes, out-of-range seeks, or exceptions outside the intended format-specific error mapping. A bounded binary reader with checked arithmetic would remove most of this duplicated risk. + +### ABI pointer and structure validation + +The exported functions do not fully validate their C ABI inputs: + +- `OpenStorage()` checks `storage` but not `params.FilePath` or `info`; +- `GetItem()` does not check `item_info`; +- `ExtractItem()` assumes `Callbacks.FileProgress` is non-null; +- `LoadSubModule()` does not check its pointer or `StructSize` and is declared `noexcept`; +- the `StructSize` members supplied by Observer are otherwise ignored. + +Invalid host input can produce an access violation instead of a defined Observer error result. + +### Missing signature data disables format validation + +[`archive::open()`](../src/archive.cpp#L28) verifies the signature only when the `Data` span is non-empty. With empty signature data, any readable file reaches the format-specific `get_archive_info()`, which currently returns constant metadata. + +Whether this is observable in production depends on Observer's exact two-stage open protocol. The test harness explicitly notes that it does not yet reproduce that protocol. + +### Entry path handling + +Archive paths are mostly passed through unchanged except for slash replacement. Zanzarah removes only one leading `..\`; the other formats do not normalize traversal components. + +The final impact depends on how Observer constructs `DestPath`, because the module receives the destination rather than joining it itself. Nevertheless, the trust boundary should be made explicit and entry paths should be validated before exposure. + +### Exceptions at the ABI boundary + +The exported functions catch common `runtime_error` and `logic_error` cases, but allocation failures and some invalid-input failures are not covered consistently. C++ exceptions must not be allowed to escape into the Observer ABI. + +Header writes are also outside the body-write error mapping in `archive::extract_file()`, so an output failure while writing an entry header is reported as a generic system error rather than `SER_ERROR_WRITE`. + +## Ren'Py Pickle compatibility debt + +The local parser is deliberately a subset rather than a general Pickle implementation. Important limitations include: + +- `BINPUT`, `LONG_BINPUT`, and `MEMOIZE` store null placeholders; +- `BINGET` and `LONG_BINGET` push `None` instead of the memoized value; +- several declared opcodes have no implementation; +- `BINFLOAT` reads the payload as little-endian even though the Pickle opcode uses big-endian representation; +- `LONG1` values longer than eight bytes can overflow the signed 64-bit accumulator; +- frame sizes and protocol versions are read but not validated. + +The current real-world corpus evidently stays inside the supported subset, but support for all valid RPA-2.0/RPA-3.0 indexes should not be assumed. + +## Extraction semantics + +- Progress is reported for body chunks but not for an optional Ren'Py entry header, even though the header contributes to the size exposed by `GetItem()`. +- Partial output is not cleaned up after cancellation or read failure. +- One archive object owns one mutable `ifstream`; concurrent extraction through the same storage handle would race on `seekg()` and `read()`. +- An empty archive is reparsed on each `PrepareFiles()` call because an empty `files_` vector is also used as the "not prepared" state. + +These may be acceptable under current Observer call patterns, but those assumptions are not encoded in the internal API. + +## Existing test coverage + +The repository contains 26 end-to-end golden tests: + +- 14 Ren'Py archives; +- 9 RPG Maker archives; +- 3 Zanzarah archives. + +Each test compares the listed path, extracted size, and XXH3 hash. Loading all three modules for every archive also checks that exactly one module accepts the signature. This is a strong regression suite for the known corpus. + +Current gaps: + +- malformed and truncated archives; +- boundary values for lengths, counts, offsets, and headers; +- cancellation and progress accounting; +- invalid ABI pointers and structure sizes; +- short archives below 128 KiB; +- multi-segment Ren'Py entries and Pickle memo references; +- concurrency assumptions; +- the real Observer two-stage `OpenStorage()` sequence. + +The corpus and expected listings live outside the repository under `M:\observer\_test`, so the tests are not self-contained. + +## Suggested order for later remediation + +1. Fix cancellation propagation and Zanzarah's capacity loop. +2. Introduce checked/bounded binary reads and validate entry ranges against the archive size. +3. Harden every exported ABI entry point and guarantee that no exception crosses it. +4. Decide whether to complete the Pickle subset or replace it with a narrower parser designed specifically for Ren'Py indexes. +5. Add focused unit and negative tests alongside the existing real-archive golden tests. +6. Define path-normalization, partial-output, progress, and concurrency contracts explicitly. + +Build-system and development-workflow redesign are intentionally outside the scope of this document and can be addressed separately. diff --git a/docs/garbro.md b/docs/garbro.md new file mode 100644 index 0000000..842defb --- /dev/null +++ b/docs/garbro.md @@ -0,0 +1,421 @@ +# GARbro Observer Module — Implementation Plan + +## Architecture + +``` +dll.cpp (C, Observer API exports) + → garbro_archive.h/cpp (pure C++, orchestration) + → bridge.h/cpp (C++/CLI behind pimpl, talks to GARbro) + → GARbro.Core.dll (ILRepack-merged: GameRes + ArcFormats + all deps) +``` + +Only `bridge.cpp` compiles with `/clr`. All other files are pure native C++. + +### Distribution Layout + +``` +modules/ +├── garbro.so ← C++/CLI mixed-mode DLL (platform-specific: x86 or x64) +├── observer_user.ini ← generated filter list (all GARbro extensions) +└── garbro/ + ├── GARbro.Core.dll ← ILRepack-merged assembly (Any CPU, ~10-15 MB) + └── Formats.dat ← encryption schemes database +``` + +### Repository Layout + +``` +ObserverModules/ +├── extern/ +│ └── GARbro/ ← git submodule +├── tools/ +│ └── gen_ini/ ← C# console tool: generates observer_user.ini +│ ├── gen_ini.csproj +│ └── Program.cs +├── src/ +│ ├── api.h +│ ├── modules/ +│ │ └── garbro/ +│ │ ├── bridge.h ← pure C++ interface (pimpl) +│ │ ├── bridge.cpp ← C++/CLI implementation (/clr) +│ │ ├── garbro_archive.h ← pure C++ archive wrapper +│ │ ├── garbro_archive.cpp +│ │ ├── dll.cpp ← Observer API exports (pure C) +│ │ ├── garbro.def ← DLL exports +│ │ └── observer_user.ini ← template (overwritten by gen_ini) +│ └── tests/ +│ ├── garbro.cpp ← integration tests +│ └── framework/ +└── CMakeLists.txt +``` + +### CI Pipeline + +``` +Step 1: git submodule update --init (GARbro) +Step 2: nuget restore extern/GARbro +Step 3: msbuild extern/GARbro/GARbro.sln /p:Configuration=Release /p:Platform="Any CPU" +Step 4: ilrepack /out:GARbro.Core.dll GameRes.dll ArcFormats.dll ArcExtra.dll ArcLegacy.dll +Step 5: dotnet run --project tools/gen_ini (generates observer_user.ini) +Step 6: cmake --preset x64-release && cmake --build build/x64-release +Step 7: cmake --preset x86-release && cmake --build build/x86-release +Step 8: ctest (run all tests) +Step 9: cpack (package x86 + x64 ZIPs) +``` + +--- + +## Phase 0: Preparation + +### 0.1 — Interface Design (BLOCKING — all other phases depend on this) + +- [ ] **0.1.1** [PROG-A] Define `src/modules/garbro/bridge.h` — pure C++ interface with pimpl: + - `garbro::file_info` struct (path, size, packed_size) + - `garbro::archive` class (try_open, format, list_files, extract, file_count) + - `garbro::init(module_dir_path)` / `garbro::shutdown()` free functions + - init loads `GARbro.Core.dll` and `Formats.dat` from `garbro/` subfolder relative to module_dir_path + - No managed types leak outside +- [ ] **0.1.2** [REVIEW-A] Review `bridge.h` — check: no CLI types, no leaking .NET, pimpl correct, const-correctness, noexcept where appropriate +- [ ] **0.1.3** [PROG-A] Fix review findings for `bridge.h` + +### 0.2 — Build System Skeleton (can start after 0.1.1) + +- [ ] **0.2.1** [PROG-B] Add GARbro as git submodule at `extern/GARbro` +- [ ] **0.2.2** [PROG-B] Add `garbro` target to `CMakeLists.txt`: + - Shared library, output `garbro.so` + - `bridge.cpp` compiled with `/clr` and `/EHa` flags (per-file property) + - All files compiled with `/MD` (dynamic CRT, required by `/clr`) + - Other files compiled as native C++ + - Reference `GARbro.Core.dll` via `#using` + - Add `garbro.def` with `LoadSubModule` / `UnloadSubModule` exports +- [ ] **0.2.3** [PROG-B] Create `src/modules/garbro/garbro.def` +- [ ] **0.2.4** [PROG-B] Verify the skeleton compiles (empty stubs) +- [ ] **0.2.5** [REVIEW-B] Review CMake changes — check: /clr only on bridge.cpp, /MD not conflicting with other modules, correct assembly references, both x86 and x64 presets work +- [ ] **0.2.6** [PROG-B] Fix review findings for build system + +### 0.3 — GARbro Build + ILRepack (parallel with 0.2) + +- [ ] **0.3.1** [PROG-B] Create build script `scripts/build_garbro.bat`: + - `nuget restore extern/GARbro` + - `msbuild extern/GARbro/GARbro.sln /p:Configuration=Release /p:Platform="Any CPU"` + - `ilrepack /out:GARbro.Core.dll GameRes.dll ArcFormats.dll ArcExtra.dll ArcLegacy.dll ` + - Copy `GARbro.Core.dll` + `Formats.dat` to build output +- [ ] **0.3.2** [PROG-B] Verify ILRepack produces working `GARbro.Core.dll`: + - Load in test harness + - FormatCatalog.Instance initializes + - ArcFormats discovered +- [ ] **0.3.3** [REVIEW-B] Review build script — check: all deps included in ILRepack, Formats.dat copied, idempotent +- [ ] **0.3.4** [PROG-B] Fix review findings + +### 0.4 — Extension List Generator (parallel with 0.2, 0.3) + +- [ ] **0.4.1** [PROG-C] Create `tools/gen_ini/gen_ini.csproj` — .NET console app referencing `GARbro.Core.dll` +- [ ] **0.4.2** [PROG-C] Implement `tools/gen_ini/Program.cs`: + - Load `FormatCatalog.Instance` + - Load `Formats.dat` scheme + - Enumerate `catalog.ArcFormats.SelectMany(f => f.Extensions)` + - Deduplicate, sort, format as `*.ext` + - Output `observer_user.ini` with `[Modules]` and `[Filters]` sections +- [ ] **0.4.3** [PROG-C] Write tests for gen_ini: + - Output is valid INI format + - Contains `[Modules]` section with `GARbro=modules\garbro.so` + - Contains `[Filters]` section with comma-separated extensions + - No empty extensions in output + - No duplicate extensions +- [ ] **0.4.4** [PROG-C] Verify gen_ini runs after ILRepack and produces correct output +- [ ] **0.4.5** [REVIEW-C] Review gen_ini — check: handles empty extensions, deduplication, INI escaping +- [ ] **0.4.6** [PROG-C] Fix review findings + +--- + +## Phase 1: Bridge Layer (C++/CLI ↔ GARbro) + +All items in Phase 1 can run **in parallel** with Phase 2 (archive layer) once `bridge.h` is finalized. + +### 1.1 — Init/Shutdown + +- [ ] **1.1.1** [PROG-A] Write tests for `garbro::init()` / `garbro::shutdown()`: + - init loads FormatCatalog from `GARbro.Core.dll`, loads `Formats.dat` scheme + - double-init is safe (idempotent) + - shutdown after init doesn't crash + - shutdown without init doesn't crash +- [ ] **1.1.2** [PROG-A] Implement `garbro::init()` and `garbro::shutdown()` in `bridge.cpp`: + - Use `GetModuleFileName()` to find own DLL path + - Resolve `garbro/GARbro.Core.dll` and `garbro/Formats.dat` relative to it + - Load assembly, initialize FormatCatalog, deserialize scheme +- [ ] **1.1.3** [PROG-A] Verify tests pass, check coverage — must be 100% lines+branches +- [ ] **1.1.4** [REVIEW-A] Review init/shutdown — check: thread safety, resource leaks, exception handling across managed/native boundary, path resolution correct +- [ ] **1.1.5** [PROG-A] Fix review findings + +### 1.2 — Format Detection (try_open) + +- [ ] **1.2.1** [PROG-A] Write tests for `garbro::archive::try_open()`: + - Valid archive → returns non-null, format() returns correct tag + - Invalid file → returns nullptr + - Non-existent path → returns nullptr (no exception) + - Empty file → returns nullptr + - Test with at least 3 different archive formats from GARbro test data +- [ ] **1.2.2** [PROG-A] Implement `try_open()` in `bridge.cpp`: + - Create `ArcView` from path + - Call `ArcFile::TryOpen()` + - Store `ArcFile^` in pimpl via `gcroot<>` + - Return format tag via `format()` +- [ ] **1.2.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **1.2.4** [REVIEW-A] Review try_open — check: ArcView lifecycle, GCHandle pinning, wstring conversion correctness, exception translation +- [ ] **1.2.5** [PROG-A] Fix review findings + +### 1.3 — File Listing (list_files / file_count) + +- [ ] **1.3.1** [PROG-A] Write tests for `list_files()` and `file_count()`: + - Known archive → correct file count + - Known archive → correct file names, sizes, packed_sizes + - Archive with subdirectories → paths preserved with backslashes + - PackedEntry → packed_size != size + - Empty archive (0 files) → empty list +- [ ] **1.3.2** [PROG-A] Implement `list_files()` and `file_count()`: + - Iterate `ArcFile::Dir` + - Convert `Entry` / `PackedEntry` → `garbro::file_info` + - Handle path encoding (Shift-JIS / UTF-8) +- [ ] **1.3.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **1.3.4** [REVIEW-A] Review list_files — check: encoding conversion, PackedEntry detection, memory allocation +- [ ] **1.3.5** [PROG-A] Fix review findings + +### 1.4 — File Extraction (extract) + +- [ ] **1.4.1** [PROG-A] Write tests for `extract()`: + - Extract known file → output matches expected bytes (hash check) + - Extract compressed file → decompressed correctly + - Progress callback receives bytes + - Progress callback returning false → extraction aborts + - Extract to non-writable path → throws write_error + - Index out of range → throws +- [ ] **1.4.2** [PROG-A] Implement `extract()`: + - Call `ArcFile::OpenEntry()` to get Stream + - Read stream in 128KB chunks + - Write to dest path + - Call progress callback per chunk +- [ ] **1.4.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **1.4.4** [REVIEW-A] Review extract — check: stream disposal, large file handling, progress granularity, exception safety +- [ ] **1.4.5** [PROG-A] Fix review findings + +### 1.5 — Destructor / Resource Cleanup + +- [ ] **1.5.1** [PROG-A] Write tests: + - Destroy archive → no leaks (ArcView disposed) + - Destroy after partial extraction → clean shutdown + - Move semantics work correctly +- [ ] **1.5.2** [PROG-A] Implement destructor — dispose GCHandle, release ArcFile +- [ ] **1.5.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **1.5.4** [REVIEW-A] Review destructor — check: prevent double-free, prevent access after dispose +- [ ] **1.5.5** [PROG-A] Fix review findings + +--- + +## Phase 2: Archive Layer (pure C++) + +Can run **in parallel** with Phase 1 once `bridge.h` is finalized. +Uses mock/stub of bridge for unit testing. + +### 2.1 — Mock Bridge + +- [ ] **2.1.1** [PROG-B] Create `mock_bridge.h/cpp` — test double for `garbro::archive`: + - Configurable: set file list, set extract behavior, set format string + - Tracks calls: open count, extract calls, last progress callback +- [ ] **2.1.2** [REVIEW-B] Review mock — check: covers all bridge.h methods, configurable error injection +- [ ] **2.1.3** [PROG-B] Fix review findings + +### 2.2 — Archive Wrapper + +- [ ] **2.2.1** [PROG-B] Write tests for `garbro_archive` (new class, does NOT reuse extractor.h): + - `open()` → delegates to `garbro::archive::try_open()`, returns archive_info with format + - `open()` with invalid file → throws + - `prepare_files()` → populates file list from bridge + - `get_file()` → returns correct file_info by index + - `get_file()` out of range → throws out_of_range + - `extract_file()` → delegates to bridge extract with progress callback + - `extract_file()` user abort → throws user_interrupt + - Path separators normalized to backslash +- [ ] **2.2.2** [PROG-B] Implement `garbro_archive` in `src/modules/garbro/garbro_archive.h/cpp`: + - Wraps `garbro::archive` (from bridge.h) + - Adapts to same interface pattern as `archive::archive` but without extractor dependency + - Converts `garbro::file_info` to internal file struct +- [ ] **2.2.3** [PROG-B] Verify tests pass, 100% coverage +- [ ] **2.2.4** [REVIEW-B] Review archive wrapper — check: exception translation, callback wiring, no resource leaks +- [ ] **2.2.5** [PROG-B] Fix review findings + +--- + +## Phase 3: DLL Entry Points (Observer API) + +Depends on Phase 2 interface being stable. Can start writing tests while Phase 1+2 finish. + +### 3.1 — dll.cpp for GARbro Module + +- [ ] **3.1.1** [PROG-C] Write tests for `OpenStorage`: + - Valid archive → SOR_SUCCESS, storage handle set, StorageGeneralInfo populated + - Invalid file → SOR_INVALID_FILE + - Null storage pointer → SOR_INVALID_FILE + - Format field ≤ 32 wchars +- [ ] **3.1.2** [PROG-C] Write tests for `CloseStorage`: + - Close valid handle → no crash + - Close null handle → no crash +- [ ] **3.1.3** [PROG-C] Write tests for `PrepareFiles`: + - After open → TRUE + - Null handle → FALSE + - Double prepare → TRUE (idempotent) +- [ ] **3.1.4** [PROG-C] Write tests for `GetItem`: + - Valid index → GET_ITEM_OK, StorageItemInfo populated (path, size, packed_size) + - Index past end → GET_ITEM_NOMOREITEMS + - Negative index → GET_ITEM_ERROR + - Null handle → GET_ITEM_ERROR + - Path encoding correct (Japanese filenames → wchar_t) +- [ ] **3.1.5** [PROG-C] Write tests for `ExtractItem`: + - Valid extraction → SER_SUCCESS, file created at DestPath + - Read error → SER_ERROR_READ + - Write error → SER_ERROR_WRITE + - User abort (callback returns false) → SER_USERABORT + - Null handle → SER_ERROR_SYSTEM +- [ ] **3.1.6** [PROG-C] Write tests for `LoadSubModule` / `UnloadSubModule`: + - LoadSubModule fills ModuleId, ModuleVersion, ApiVersion, ApiFuncs + - All function pointers non-null + - UnloadSubModule doesn't crash +- [ ] **3.1.7** [PROG-C] Implement `src/modules/garbro/dll.cpp`: + - `LoadSubModule` → call `garbro::init(module_dir)`, fill params + - `UnloadSubModule` → call `garbro::shutdown()` + - `OpenStorage` → create garbro_archive, try open + - Other functions follow existing dll.cpp pattern +- [ ] **3.1.8** [PROG-C] Verify tests pass, 100% coverage +- [ ] **3.1.9** [REVIEW-C] Review dll.cpp — check: matches existing module pattern, handle lifecycle, exception safety at C boundary, no exceptions escape extern "C" +- [ ] **3.1.10** [PROG-C] Fix review findings + +--- + +## Phase 4: Integration Testing + +Depends on Phases 1, 2, 3 all complete. + +### 4.1 — End-to-End Tests via DLL Loading + +- [ ] **4.1.1** [PROG-D] Create test archives from at least 5 different GARbro-supported formats: + - KiriKiri XP3 + - AliceSoft ALD + - Ren'Py RPA (overlap with existing module — verify both work) + - RPG Maker (overlap with existing module — verify both work) + - One more format (e.g., NScripter NSA, Majiro ARC) +- [ ] **4.1.2** [PROG-D] Generate `.expected.json` baselines for each test archive (hash + size) +- [ ] **4.1.3** [PROG-D] Write `src/tests/garbro.cpp` — Catch2 test cases using existing `test::observer` framework: + - Load garbro.so module + - Open each test archive + - List files, verify count and names + - Extract all files, verify hashes match baseline +- [ ] **4.1.4** [PROG-D] Run integration tests, fix failures +- [ ] **4.1.5** [REVIEW-D] Review integration tests — check: deterministic, no hardcoded paths, cleanup temp files, covers error paths too +- [ ] **4.1.6** [PROG-D] Fix review findings + +### 4.2 — Coexistence Test + +- [ ] **4.2.1** [PROG-D] Test that garbro module and existing modules (renpy, rpgmaker) can load simultaneously +- [ ] **4.2.2** [PROG-D] Test that Observer tries garbro module when existing modules reject a file +- [ ] **4.2.3** [PROG-D] Test that existing modules take priority for their registered extensions (*.rpa, *.rgss3a) + +--- + +## Phase 5: Packaging + +Can start partially during Phase 4. + +### 5.1 — CPack Integration + +- [ ] **5.1.1** [PROG-B] Add CPack rules for garbro module: + - `garbro-{DATE}-{ARCH}-dll.zip` contents: + - `garbro.so` (platform-specific) + - `observer_user.ini` (generated by gen_ini) + - `garbro/GARbro.Core.dll` (Any CPU, ILRepack-merged) + - `garbro/Formats.dat` + - `licenses/` + - `garbro-{DATE}-{ARCH}-pdb.zip` with debug symbols + - Both x86 and x64 packages (same GARbro.Core.dll, different garbro.so) +- [ ] **5.1.2** [REVIEW-B] Review packaging — check: all runtime files included, folder structure correct, .NET Framework 4.7.2 noted as prerequisite +- [ ] **5.1.3** [PROG-B] Fix review findings + +--- + +## Phase 6: Final Review + +- [ ] **6.1** [REVIEW-ALL] Full code review of all files: + - Consistent style with existing ObserverModules code + - No memory leaks across managed/native boundary + - No exceptions escaping extern "C" functions + - All error paths tested + - 100% line + branch coverage confirmed +- [ ] **6.2** [PROG-ALL] Fix all final review findings +- [ ] **6.3** [REVIEW-ALL] Confirm zero open findings +- [ ] **6.4** Run full test suite (all modules including garbro), both x86 and x64 +- [ ] **6.5** Build release packages for x86 and x64 + +--- + +## Parallelism Map + +``` +Phase 0.1 (bridge.h interface) + │ + ├───────────────────────┬──────────────────┐ + ▼ ▼ ▼ +Phase 0.2 Phase 0.3 Phase 0.4 +(CMake skeleton) (GARbro build (gen_ini tool) +[PROG-B] + ILRepack) [PROG-C] + [PROG-B] + │ │ │ + ├───────────────────────┴──────────────────┘ + │ + ├──────────────────┬──────────────────┐ + ▼ ▼ ▼ +Phase 1 Phase 2 Phase 3.1.1-3.1.6 +(bridge impl) (archive layer) (dll.cpp tests) +[PROG-A] [PROG-B] [PROG-C] + │ │ │ + └──────────────────┴──────────────────┘ + │ + ▼ + Phase 3.1.7-3.1.10 + (dll.cpp impl) + │ + ▼ + Phase 4 + (integration) + [PROG-D] + │ + ▼ + Phase 5 + (packaging) + │ + ▼ + Phase 6 + (final review) +``` + +## Agent Roles + +| Role | Responsibility | +|------|---------------| +| **PROG-A** | Bridge layer (C++/CLI) — `bridge.h`, `bridge.cpp`, bridge tests | +| **PROG-B** | Build system + GARbro build + ILRepack + archive layer + mock + packaging | +| **PROG-C** | gen_ini tool + DLL entry points — `dll.cpp`, Observer API tests | +| **PROG-D** | Integration tests — end-to-end, coexistence, test data | +| **REVIEW-A** | Reviews PROG-A output | +| **REVIEW-B** | Reviews PROG-B output | +| **REVIEW-C** | Reviews PROG-C output | +| **REVIEW-D** | Reviews PROG-D output | +| **REVIEW-ALL** | Final cross-cutting review | + +## Technical Notes + +- `/clr` is incompatible with `/EHsc` — use `/EHa` for `bridge.cpp` +- `/clr` is incompatible with static CRT (`/MT`) — the entire garbro module must use `/MD` (dynamic CRT); this does NOT affect other modules (renpy, rpgmaker, zanzarah) which keep `/MT` +- `gcroot` is the correct way to store managed references in native classes (inside pimpl) +- GARbro uses `BinaryFormatter` for `Formats.dat` — requires .NET security settings in 4.7.2+ +- ILRepack NuGet: `dotnet tool install --global ILRepack` or download from https://github.com/gluck/il-repack +- `GARbro.Core.dll` is Any CPU — works in both x86 and x64 CLR contexts +- Test archives should be small (< 1MB each) and committed to the test data directory +- To update GARbro: `cd extern/GARbro && git pull && cd ../.. && git add extern/GARbro && git commit` diff --git a/docs/quickbms.md b/docs/quickbms.md new file mode 100644 index 0000000..7f0a230 --- /dev/null +++ b/docs/quickbms.md @@ -0,0 +1,518 @@ +# QuickBMS Observer Module — Implementation Plan + +## Architecture + +``` +dll.cpp (C, Observer API exports — same pattern as other modules) + → quickbms_archive.h/cpp (pure C++, orchestration + scripts.ini parsing) + → quickbms.lib (QuickBMS compiled as static library, pure C) +``` + +Everything is **native C/C++**. No .NET, no interop, no managed code. + +Does NOT use `extractor.h` — QuickBMS extraction is opaque (BMS script handles +everything internally via `dumpa()`), incompatible with chunk-based `decrypt()` model. + +### Distribution Layout + +``` +modules/ +├── quickbms.so ← native C/C++ DLL +├── observer_user.ini ← generated from scripts.ini (all mapped extensions) +└── quickbms/ + ├── scripts.ini ← extension → BMS script mapping (user-editable) + ├── scripts/ + │ ├── kirikiri_xp3.bms ← example scripts (bundled) + │ ├── unreal_pak.bms + │ ├── unity_assets.bms + │ ├── rpgmaker_vxace.bms + │ └── ... + └── docs/ + └── bms_syntax.md ← BMS scripting reference +``` + +### Repository Layout + +``` +ObserverModules/ +├── extern/ +│ └── quickbms/ ← git submodule or vendored source +│ ├── quickbms.c +│ ├── bms.c, cmd.c, perform.c, file.c, var.c, ... +│ ├── compression/ +│ ├── encryption/ +│ └── libs/ +├── tools/ +│ └── gen_quickbms_ini/ ← generates observer_user.ini from scripts.ini +├── src/ +│ ├── api.h +│ ├── modules/ +│ │ └── quickbms/ +│ │ ├── quickbms_archive.h ← pure C++ archive wrapper +│ │ ├── quickbms_archive.cpp +│ │ ├── quickbms_wrapper.h ← C++ wrapper around QuickBMS C internals +│ │ ├── quickbms_wrapper.cpp +│ │ ├── scripts_config.h ← scripts.ini parser +│ │ ├── scripts_config.cpp +│ │ ├── dll.cpp ← Observer API exports +│ │ ├── quickbms.def +│ │ └── observer_user.ini ← template +│ └── tests/ +│ ├── quickbms.cpp ← integration tests +│ ├── quickbms_wrapper_test.cpp ← wrapper unit tests +│ └── scripts_config_test.cpp ← config parser tests +└── CMakeLists.txt +``` + +### CI Pipeline + +``` +Step 1: git submodule update --init (quickbms) +Step 2: cmake --preset x64-release (builds quickbms.lib + quickbms.so) +Step 3: cmake --preset x86-release (builds quickbms.lib + quickbms.so) +Step 4: python/script gen observer_user.ini from scripts.ini +Step 5: ctest (run all tests) +Step 6: cpack (package x86 + x64 ZIPs) +``` + +--- + +## scripts.ini Format + +```ini +# Extension → BMS script mapping +# Multiple scripts separated by comma: tried in order until one succeeds +# Lines starting with # are comments + +[Scripts] +*.xp3 = kirikiri_xp3.bms +*.arc = arc_will.bms, arc_lzss.bms +*.pak = unreal_pak.bms, quake_pak.bms +*.dat = bgi_arc.bms, falcom_dat.bms +*.cpz = cmvs_cpz.bms +*.ypf = yumemiru_ypf.bms +*.nsa = nscripter_nsa.bms +*.wolf = wolfrpg.bms +``` + +No fallback — only explicitly mapped extensions are handled. +Observer `OpenStorage` returns `SOR_INVALID_FILE` for unmapped extensions. + +--- + +## Phase 0: Preparation + +### 0.1 — QuickBMS Static Library Build (BLOCKING) + +- [ ] **0.1.1** [PROG-A] Add QuickBMS source as git submodule at `extern/quickbms` +- [ ] **0.1.2** [PROG-A] Patch `file.c` — add progress callback hook into `dumpa()`: + - Add global function pointer: `int (*g_observer_progress_callback)(void *ctx, int64_t bytes) = NULL;` + - Add global context pointer: `void *g_observer_progress_context = NULL;` + - After each `write()` call inside `dumpa()`, invoke the callback: + ```c + if (g_observer_progress_callback) { + if (!g_observer_progress_callback(g_observer_progress_context, bytes_written)) { + return -1; // user abort + } + } + ``` + - This gives real-time progress updates and user abort support + - Keep patch minimal — only touch `dumpa()` write loop +- [ ] **0.1.3** [PROG-A] Create CMake target for `quickbms_lib` (static library): + - Compile all QuickBMS .c files except `main()` wrapper + - Define `QUICKBMS_AS_LIB` or similar preprocessor macro + - Handle the `#include`-based build: QuickBMS includes .c files from quickbms.c + - Static link all compression/encryption deps (zlib, lzma, etc. — already vendored in quickbms) + - Suppress warnings from QuickBMS code (`/W0` or pragma push/pop) + - Build for both x86 and x64 +- [ ] **0.1.4** [PROG-A] Verify `quickbms_lib` compiles cleanly on both architectures +- [ ] **0.1.5** [REVIEW-A] Review CMake integration and `dumpa()` patch — check: no symbol conflicts with other modules, all deps statically linked, no undefined symbols, patch is minimal and correct, abort path doesn't leak resources +- [ ] **0.1.6** [PROG-A] Fix review findings + +### 0.2 — C++ Wrapper Interface Design (BLOCKING for Phases 1-3) + +- [ ] **0.2.1** [PROG-B] Define `quickbms_wrapper.h` — clean C++ interface over QuickBMS C internals: + ```cpp + namespace quickbms { + struct file_entry { + std::string name; + int64_t offset; + int64_t size; // uncompressed + int64_t packed_size; // compressed + }; + + // Progress callback: receives context + bytes written. + // Return true to continue, false to abort. + using progress_callback = std::function; + + class engine { + public: + engine(); + ~engine(); + bool load_script(const std::filesystem::path &bms_path); + bool open_archive(const std::filesystem::path &archive_path); + std::vector list_files(); + bool extract_file(size_t index, const std::filesystem::path &dest_path, + progress_callback progress); + void reset(); + private: + struct impl; + std::unique_ptr impl_; + }; + } + ``` + - Pimpl hides all QuickBMS globals + - Each `engine` instance assumes exclusive access (QuickBMS global state) + - `reset()` calls `bms_init(0)` to clean up between uses + - `extract_file()` sets `g_observer_progress_callback` / `g_observer_progress_context` + before calling `start_bms()`, resets them after. The patched `dumpa()` calls back + per write chunk, giving real-time progress and user abort support. +- [ ] **0.2.2** [REVIEW-B] Review wrapper interface — check: no C types leak, lifecycle correct, thread-safety documented (single-threaded only) +- [ ] **0.2.3** [PROG-B] Fix review findings + +### 0.3 — scripts.ini Parser Design (parallel with 0.1, 0.2) + +- [ ] **0.3.1** [PROG-C] Define `scripts_config.h`: + ```cpp + namespace quickbms { + struct script_mapping { + std::string extension; // e.g. "xp3" + std::vector scripts; // e.g. ["kirikiri_xp3.bms"] + }; + + class scripts_config { + public: + explicit scripts_config(const std::filesystem::path &ini_path); + std::vector find_scripts(const std::string &extension) const; + std::vector all_extensions() const; + private: + std::vector mappings_; + }; + } + ``` +- [ ] **0.3.2** [REVIEW-C] Review config interface — check: case-insensitive extension matching, handles dots/wildcards correctly +- [ ] **0.3.3** [PROG-C] Fix review findings + +--- + +## Phase 1: scripts.ini Parser + +Can start immediately after 0.3 is finalized. + +### 1.1 — Implementation + +- [ ] **1.1.1** [PROG-C] Write tests for `scripts_config`: + - Parse valid INI → correct mappings + - Extension lookup case-insensitive ("XP3" == "xp3") + - Multiple scripts per extension → returned in order + - Unknown extension → empty vector + - `all_extensions()` returns sorted unique list + - Missing file → throws + - Empty file → no mappings + - Comments (# lines) ignored + - Malformed lines skipped gracefully +- [ ] **1.1.2** [PROG-C] Implement `scripts_config.cpp` +- [ ] **1.1.3** [PROG-C] Verify tests pass, 100% line+branch coverage +- [ ] **1.1.4** [REVIEW-C] Review — check: no buffer overflows on long lines, handles BOM, handles \r\n and \n +- [ ] **1.1.5** [PROG-C] Fix review findings + +--- + +## Phase 2: QuickBMS Wrapper + +Depends on Phase 0.1 (static lib) and 0.2 (wrapper interface). +Can run **in parallel** with Phase 1 and Phase 3. + +### 2.1 — Init / Reset / Cleanup + +- [ ] **2.1.1** [PROG-A] Write tests for `quickbms::engine` construction and destruction: + - Constructor initializes QuickBMS (`quickbms_dll_init`, `bms_init`) + - Destructor cleans up (`bms_finish`) + - `reset()` clears state for reuse + - Double reset is safe +- [ ] **2.1.2** [PROG-A] Implement constructor, destructor, `reset()` in `quickbms_wrapper.cpp` +- [ ] **2.1.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **2.1.4** [REVIEW-A] Review — check: all QuickBMS globals properly initialized, no memory leaks from QuickBMS internal allocations +- [ ] **2.1.5** [PROG-A] Fix review findings + +### 2.2 — Script Loading + +- [ ] **2.2.1** [PROG-A] Write tests for `load_script()`: + - Valid .bms file → returns true + - Non-existent file → returns false + - Invalid/empty script → returns false + - Load replaces previous script (after reset) +- [ ] **2.2.2** [PROG-A] Implement `load_script()`: + - Call `bms_init(0)` to reset state + - Open script file + - Call `parse_bms(fds, NULL, 0, 0)` + - Return success/failure +- [ ] **2.2.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **2.2.4** [REVIEW-A] Review — check: file handle closed on error, state consistent after failure +- [ ] **2.2.5** [PROG-A] Fix review findings + +### 2.3 — Archive Opening + File Listing + +- [ ] **2.3.1** [PROG-A] Write tests for `open_archive()` + `list_files()`: + - Valid archive + matching script → file list with correct names, sizes + - Wrong script for archive → returns false or empty list + - `list_files()` without `open_archive()` → empty list + - File names contain subdirectories → paths preserved + - Packed entries → packed_size != size +- [ ] **2.3.2** [PROG-A] Implement `open_archive()` and `list_files()`: + - `open_archive()`: call `fdnum_open(path, 0, 1)` + - `list_files()`: set `g_list_only = 1`, call `start_bms(...)`, iterate `g_extracted_file` linked list + - Convert `extracted_file_t` → `quickbms::file_entry` +- [ ] **2.3.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **2.3.4** [REVIEW-A] Review — check: g_list_only properly set/unset, extracted_file_t iteration safe, memory ownership +- [ ] **2.3.5** [PROG-A] Fix review findings + +### 2.4 — File Extraction with Progress + +- [ ] **2.4.1** [PROG-A] Write tests for `extract_file()`: + - Extract known file → output matches expected bytes (hash check) + - Extract compressed file → decompressed correctly + - Progress callback called with byte counts (per write chunk from `dumpa()`) + - Progress callback returning false → extraction aborts, returns false + - After abort, engine is in clean state (can extract another file) + - Index out of range → returns false + - Dest path with subdirectories → created automatically +- [ ] **2.4.2** [PROG-A] Implement `extract_file()`: + - Set `g_list_only = 0`, `g_void_dump = 0` + - Set `g_output_folder` to dest directory + - Install progress hook: set `g_observer_progress_callback` to a static trampoline + that calls the `progress_callback`, set `g_observer_progress_context` to `this` + - Re-run `start_bms(...)` — QuickBMS re-executes script and extracts + - Filter output to only the requested file (by index/name) + - `dumpa()` calls our hook per write chunk → real-time progress to Observer + - If hook returns false (user abort), `dumpa()` returns -1, script terminates + - Uninstall progress hook after extraction (set both globals to NULL) + - Note: QuickBMS re-executes entire script per extract; acceptable for typical use +- [ ] **2.4.3** [PROG-A] Verify tests pass, 100% coverage +- [ ] **2.4.4** [REVIEW-A] Review — check: hook installed/uninstalled correctly (RAII guard), output path injection (sanitize filenames), abort leaves no partial files, re-execution overhead acceptable +- [ ] **2.4.5** [PROG-A] Fix review findings + +--- + +## Phase 3: Archive Layer + DLL Entry Points + +Can run **in parallel** with Phase 2 using mock wrapper. + +### 3.1 — Mock Wrapper + +- [ ] **3.1.1** [PROG-B] Create `mock_quickbms_wrapper.h/cpp` — test double for `quickbms::engine`: + - Configurable: set file list, set extract behavior + - Tracks calls: script loaded, archive opened, files extracted + - Configurable error injection +- [ ] **3.1.2** [REVIEW-B] Review mock +- [ ] **3.1.3** [PROG-B] Fix review findings + +### 3.2 — quickbms_archive + +- [ ] **3.2.1** [PROG-B] Write tests for `quickbms_archive`: + - `open(path)` → reads scripts.ini, finds scripts for extension, tries each until one works + - `open(path)` with unmapped extension → throws (SOR_INVALID_FILE) + - `open(path)` with mapped extension but wrong format → tries all scripts, then throws + - `prepare_files()` → delegates to engine.list_files() + - `get_file(index)` → returns file info + - `get_file()` out of range → throws out_of_range + - `extract_file()` → delegates to engine.extract_file() with progress callback + - `extract_file()` user abort → throws user_interrupt + - Path separators normalized to backslash + - `archive_info` format field contains BMS script name or detected format +- [ ] **3.2.2** [PROG-B] Implement `quickbms_archive` in `src/modules/quickbms/quickbms_archive.h/cpp`: + - Owns `quickbms::engine` and `quickbms::scripts_config` + - On `open()`: get extension, look up scripts, try each with engine + - Stores successful script name and file list +- [ ] **3.2.3** [PROG-B] Verify tests pass, 100% coverage +- [ ] **3.2.4** [REVIEW-B] Review — check: exception translation, script path resolution, lifecycle +- [ ] **3.2.5** [PROG-B] Fix review findings + +### 3.3 — dll.cpp + +- [ ] **3.3.1** [PROG-C] Write tests for Observer API functions: + - `LoadSubModule` → fills ModuleId, ModuleVersion, ApiVersion, ApiFuncs + - `OpenStorage` → valid archive with mapped extension → SOR_SUCCESS + - `OpenStorage` → unmapped extension → SOR_INVALID_FILE + - `CloseStorage` → no crash (null and valid handle) + - `PrepareFiles` → TRUE after open + - `GetItem` → correct file info, GET_ITEM_NOMOREITEMS at end + - `ExtractItem` → SER_SUCCESS, file created, SER_USERABORT on cancel + - `UnloadSubModule` → clean shutdown +- [ ] **3.3.2** [PROG-C] Implement `src/modules/quickbms/dll.cpp`: + - `LoadSubModule` → resolve module dir via `GetModuleFileName`, load scripts.ini from `quickbms/` subfolder + - `OpenStorage` → create quickbms_archive, try open + - Other functions follow existing dll.cpp pattern +- [ ] **3.3.3** [PROG-C] Create `src/modules/quickbms/quickbms.def` +- [ ] **3.3.4** [PROG-C] Verify tests pass, 100% coverage +- [ ] **3.3.5** [REVIEW-C] Review dll.cpp — check: no exceptions escape extern "C", handle lifecycle, path resolution +- [ ] **3.3.6** [PROG-C] Fix review findings + +--- + +## Phase 4: Integration Testing + +Depends on Phases 1, 2, 3 all complete. + +### 4.1 — End-to-End Tests + +- [ ] **4.1.1** [PROG-D] Select 3-5 test archives with publicly available BMS scripts: + - At least one simple format (no compression) + - At least one with compression + - At least one with encryption + - Small archives (< 1MB each) +- [ ] **4.1.2** [PROG-D] Create scripts.ini with mappings for test archives +- [ ] **4.1.3** [PROG-D] Generate `.expected.json` baselines (hash + size) +- [ ] **4.1.4** [PROG-D] Write `src/tests/quickbms.cpp` — Catch2 tests using `test::observer` framework: + - Load quickbms.so module + - Open each test archive + - List files, verify count and names + - Extract all files, verify hashes +- [ ] **4.1.5** [PROG-D] Run integration tests, fix failures +- [ ] **4.1.6** [REVIEW-D] Review — check: deterministic, no hardcoded paths, temp cleanup +- [ ] **4.1.7** [PROG-D] Fix review findings + +### 4.2 — Coexistence Test + +- [ ] **4.2.1** [PROG-D] Test that quickbms.so, garbro.so, and existing modules load simultaneously +- [ ] **4.2.2** [PROG-D] Test module priority: native modules → garbro → quickbms (by extension ordering in observer_user.ini) + +--- + +## Phase 5: Documentation & Example Scripts + +Can run **in parallel** with Phase 4. + +### 5.1 — BMS Syntax Reference + +- [ ] **5.1.1** [PROG-D] Find or write `quickbms/docs/bms_syntax.md`: + - Core commands: Get, Set, Log, CLog, GoTo, For/Next, If/Else/EndIf + - Data types: Long, Short, Byte, String, ThreeByte, etc. + - Math operations + - Compression (ComType) and Encryption commands + - Variables: FILENAME, FILESIZE, etc. + - Example script walkthrough + - Link to full QuickBMS documentation (zenhax.com/quickbms) +- [ ] **5.1.2** [REVIEW-D] Review documentation — check: accurate, covers essentials for writing custom scripts +- [ ] **5.1.3** [PROG-D] Fix review findings + +### 5.2 — Example Scripts + +- [ ] **5.2.1** [PROG-D] Bundle 5-10 popular BMS scripts in `quickbms/scripts/`: + - Include license/attribution for each script + - Cover variety: simple raw, compressed, encrypted + - Include a `README.txt` in scripts/ explaining how to add more +- [ ] **5.2.2** [REVIEW-D] Review script selection — check: licenses allow redistribution, scripts tested +- [ ] **5.2.3** [PROG-D] Fix review findings + +### 5.3 — observer_user.ini Generator + +- [ ] **5.3.1** [PROG-C] Create script/tool that reads `scripts.ini` and generates `observer_user.ini`: + - Extracts all extensions from `[Scripts]` section + - Outputs `[Filters]` with comma-separated `*.ext` list + - Simple: Python script or shell script (no need for C#) +- [ ] **5.3.2** [REVIEW-C] Review generator +- [ ] **5.3.3** [PROG-C] Fix review findings + +--- + +## Phase 6: Packaging + +### 6.1 — CPack Integration + +- [ ] **6.1.1** [PROG-B] Add CPack rules for quickbms module: + - `quickbms-{DATE}-{ARCH}-dll.zip` contents: + - `quickbms.so` (platform-specific) + - `observer_user.ini` (generated) + - `quickbms/scripts.ini` + - `quickbms/scripts/*.bms` (example scripts) + - `quickbms/docs/bms_syntax.md` + - `licenses/` + - `quickbms-{DATE}-{ARCH}-pdb.zip` with debug symbols + - Both x86 and x64 packages +- [ ] **6.1.2** [REVIEW-B] Review packaging — check: all files included, scripts.ini editable post-install +- [ ] **6.1.3** [PROG-B] Fix review findings + +--- + +## Phase 7: Final Review + +- [ ] **7.1** [REVIEW-ALL] Full code review: + - Consistent style with existing ObserverModules code + - No memory leaks (QuickBMS global state properly managed) + - No exceptions escaping extern "C" functions + - All error paths tested + - 100% line + branch coverage confirmed + - QuickBMS global state properly reset between archives +- [ ] **7.2** [PROG-ALL] Fix all final review findings +- [ ] **7.3** [REVIEW-ALL] Confirm zero open findings +- [ ] **7.4** Run full test suite (all modules), both x86 and x64 +- [ ] **7.5** Build release packages for x86 and x64 + +--- + +## Parallelism Map + +``` +Phase 0.1 (quickbms.lib) Phase 0.2 (wrapper.h) Phase 0.3 (config.h) +[PROG-A] [PROG-B] [PROG-C] + │ │ │ + │ │ ▼ + │ │ Phase 1 + │ │ (config parser) + │ │ [PROG-C] + │ │ │ + ├─────────────────────────────┘ │ + ▼ │ +Phase 2 Phase 3.1 (mock) │ +(wrapper impl) [PROG-B] │ +[PROG-A] │ │ + │ ▼ │ + │ Phase 3.2-3.3 │ + │ (archive + dll.cpp) │ + │ [PROG-B, PROG-C] │ + │ │ │ + └─────────────────────────────┴─────────────────────────┘ + │ + ▼ + Phase 4 (integration) ←→ Phase 5 (docs + scripts) + [PROG-D] [PROG-D, PROG-C] + │ + ▼ + Phase 6 (packaging) + [PROG-B] + │ + ▼ + Phase 7 (final review) +``` + +## Agent Roles + +| Role | Responsibility | +|------|---------------| +| **PROG-A** | QuickBMS static lib build + C++ wrapper (`quickbms_wrapper.*`) | +| **PROG-B** | CMake integration + mock + archive layer + packaging | +| **PROG-C** | scripts.ini parser + dll.cpp + observer_user.ini generator | +| **PROG-D** | Integration tests + documentation + example scripts | +| **REVIEW-A** | Reviews PROG-A output | +| **REVIEW-B** | Reviews PROG-B output | +| **REVIEW-C** | Reviews PROG-C output | +| **REVIEW-D** | Reviews PROG-D output | +| **REVIEW-ALL** | Final cross-cutting review | + +## Technical Notes + +- **Symbol visibility**: `quickbms.so` must export ONLY `LoadSubModule` and `UnloadSubModule` (via `.def` file). All QuickBMS internals (700+ compression funcs, crypto, globals) must be hidden: + - `.def` file lists only the two exports — MSVC exports nothing else by default for DLLs with `.def` + - QuickBMS static lib (`quickbms_lib`) is linked with `/OPT:REF` to strip unused code + - All QuickBMS source compiled without `__declspec(dllexport)` — verify no accidental exports + - This also solves symbol conflicts: if `garbro.so` and `quickbms.so` both link zlib, their internal zlib symbols are hidden and don't clash +- **Progress callback**: Patched `dumpa()` in `file.c` calls `g_observer_progress_callback` after each write. This gives real-time progress bars in FAR Manager and user abort support (callback returns false → `dumpa()` returns -1 → script terminates). +- **Global state**: QuickBMS uses ~50 global variables. Only one archive can be open at a time. `bms_init(0)` resets everything between uses. +- **Build quirk**: QuickBMS `#include`s .c files from `quickbms.c`. To build as library, either: + - Compile `quickbms.c` with a macro that excludes `main()`, OR + - Extract the `#include` list and compile files separately +- **Re-execution on extract**: `start_bms()` must re-run the entire script to extract a file. For archives with many files, this means O(N) re-executions. Acceptable for typical use (user extracts one file at a time in FAR Manager). +- **Output redirection**: QuickBMS writes to `g_output_folder`. For `ExtractItem`, set this to the directory of `DestPath` and filter by filename match. +- **No thread safety**: QuickBMS is inherently single-threaded due to global state. Observer calls are sequential, so this is fine. +- **Compression deps**: All vendored in `extern/quickbms/compression/` and `libs/` — no external dependencies needed. diff --git a/docs/unity.md b/docs/unity.md new file mode 100644 index 0000000..3a0986e --- /dev/null +++ b/docs/unity.md @@ -0,0 +1,318 @@ +# Unity Observer Module — План реализации + +## Архитектура + +``` +dll.cpp (C, экспорты Observer API) + → unity_archive.h/cpp (чистый C++, оркестрация) + → bridge.h/cpp (C++/CLI за pimpl, общение с AssetStudio) + → AssetStudio.dll (ILRepack-merged: AssetStudioUtility + все зависимости) + → Texture2DDecoderNative.dll (нативный C++ декодер текстур, загружается через P/Invoke) +``` + +Только `bridge.cpp` компилируется с `/clr`. Остальные файлы — чистый нативный C++. + +НЕ использует `extractor.h` — объекты Unity десериализуются AssetStudio и экспортируются +с конвертацией формата (Texture2D → PNG, AudioClip → WAV и т.д.), что несовместимо +с chunk-based `decrypt()` моделью. + +### Раскладка дистрибутива + +``` +modules/ +├── unity.so ← C++/CLI mixed-mode DLL (платформо-зависимый: x86 или x64) +├── observer_user.ini ← фильтр расширений +└── unity/ + ├── AssetStudio.dll ← ILRepack-merged сборка (Any CPU, ~10-15 MB) + └── Texture2DDecoderNative.dll ← нативный декодер текстур (платформо-зависимый, ~500 KB) +``` + +### Раскладка репозитория + +``` +ObserverModules/ +├── extern/ +│ └── AssetStudio/ ← git submodule (aelurum/AssetStudio) +├── src/ +│ ├── api.h +│ ├── modules/ +│ │ └── unity/ +│ │ ├── bridge.h ← чистый C++ интерфейс (pimpl) +│ │ ├── bridge.cpp ← C++/CLI реализация (/clr) +│ │ ├── unity_archive.h ← чистый C++ обёртка архива +│ │ ├── unity_archive.cpp +│ │ ├── dll.cpp ← экспорты Observer API (чистый C) +│ │ ├── unity.def ← экспорты DLL +│ │ └── observer_user.ini ← фильтр расширений +│ └── tests/ +│ ├── unity.cpp ← интеграционные тесты +│ └── framework/ +└── CMakeLists.txt +``` + +### CI Pipeline + +``` +Step 1: git submodule update --init (AssetStudio) +Step 2: dotnet restore extern/AssetStudio +Step 3: dotnet build extern/AssetStudio -c Release -f net472 +Step 4: ilrepack /out:AssetStudio.dll AssetStudioUtility.dll +Step 5: cmake --preset x64-release && cmake --build build/x64-release +Step 6: cmake --preset x86-release && cmake --build build/x86-release +Step 7: ctest (все тесты) +Step 8: cpack (пакеты x86 + x64 ZIP) +``` + +### Маппинг форматов экспорта + +При листинге файлов каждый Unity-объект показывается с соответствующим расширением. +При извлечении AssetStudio конвертирует в этот формат. + +| Тип Unity | Формат экспорта | Расширение | Примечания | +|-----------|----------------|------------|------------| +| Texture2D | PNG | `.png` | GPU-форматы декодируются через Texture2DDecoderNative | +| Sprite | PNG | `.png` | Обрезается по атласу | +| AudioClip | WAV | `.wav` | FSB/FMOD; может потребоваться нативная FMOD-библиотека | +| TextAsset | Сырые байты | `.txt` / `.bytes` | Как есть, расширение из оригинального имени | +| Font | TrueType | `.ttf` / `.otf` | Сырые данные шрифта | +| Shader | Текст | `.shader` | Исходник или дизассемблированный код | +| VideoClip | Ссылка | `.mp4` и т.д. | Обычно внешний .resource файл | +| Mesh | Сырые данные | `.mesh.bytes` | Нет стандартного просмотрщика | +| MonoBehaviour | JSON | `.json` | Сериализованные поля (если доступен type tree) | +| Прочее | Сырые байты | `.bytes` | Фоллбэк для неподдерживаемых типов | + +--- + +## Фаза 0: Подготовка + +### 0.1 — Проектирование интерфейса (БЛОКИРУЕТ остальные фазы) + +- [ ] **0.1.1** [PROG-A] Спроектировать `bridge.h` — чистый C++ интерфейс с pimpl: + - Namespace `unity`, класс `bundle` с методами: `try_open`, `format`, `asset_count`, `get_asset`, `extract` + - Структура `asset_info` (имя, тип, размер экспорта, оригинальный размер) + - Enum `asset_type` для маппинга типов Unity + - Свободные функции `init(module_dir_path)` / `shutdown()` — загрузка AssetStudio.dll из подпапки `unity/` + - Managed-типы не должны утекать наружу +- [ ] **0.1.2** [REVIEW-A] Ревью `bridge.h` +- [ ] **0.1.3** [PROG-A] Исправления по ревью + +### 0.2 — Скелет системы сборки (после 0.1.1) + +- [ ] **0.2.1** [PROG-B] Добавить aelurum/AssetStudio как git submodule в `extern/AssetStudio` +- [ ] **0.2.2** [PROG-B] Добавить таргет `unity` в `CMakeLists.txt`: + - Shared library → `unity.so` + - `bridge.cpp` с `/clr` + `/EHa` (per-file property) + - Весь модуль с `/MD` (динамический CRT, требование `/clr`) + - `unity.def` с экспортами `LoadSubModule` / `UnloadSubModule` +- [ ] **0.2.3** [PROG-B] Проверить что скелет компилируется (пустые заглушки) +- [ ] **0.2.4** [REVIEW-B] Ревью CMake +- [ ] **0.2.5** [PROG-B] Исправления по ревью + +### 0.3 — Сборка AssetStudio + ILRepack (параллельно с 0.2) + +- [ ] **0.3.1** [PROG-B] Создать скрипт `scripts/build_assetstudio.bat`: + - Сборка AssetStudioUtility под net472 + - ILRepack всех managed-зависимостей в одну `AssetStudio.dll` + - Сборка `Texture2DDecoderNative.dll` под x86 и x64 + - Копирование артефактов в build output +- [ ] **0.3.2** [PROG-B] Проверить что ILRepack-merged сборка работает: AssetsManager инициализируется, открывает тестовый .assets файл +- [ ] **0.3.3** [REVIEW-B] Ревью скрипта сборки +- [ ] **0.3.4** [PROG-B] Исправления по ревью + +--- + +## Фаза 1: Bridge Layer (C++/CLI ↔ AssetStudio) + +Может идти **параллельно** с фазой 2 после финализации `bridge.h`. + +### 1.1 — Init/Shutdown + +- [ ] **1.1.1** [PROG-A] Тесты + реализация `unity::init()` / `unity::shutdown()`: + - `GetModuleFileName()` → определение пути к своей DLL + - Загрузка AssetStudio.dll через `Assembly::LoadFrom()` + - `AddDllDirectory()` для подпапки `unity/` — чтобы P/Invoke нашёл Texture2DDecoderNative.dll + - Идемпотентность, безопасность повторных вызовов +- [ ] **1.1.2** [REVIEW-A] Ревью +- [ ] **1.1.3** [PROG-A] Исправления по ревью + +### 1.2 — Открытие ассетов (try_open) + +- [ ] **1.2.1** [PROG-A] Тесты + реализация `unity::bundle::try_open()`: + - `AssetsManager` в pimpl через `gcroot<>` + - `LoadFiles(path)` → перечисление всех `SerializedFile` и их объектов + - Построение списка `asset_info` из экспортируемых объектов + - Формирование строки формата ("Unity AssetBundle (LZ4)", "Unity Assets" и т.д.) + - Невалидный/несуществующий/пустой файл → false без исключений +- [ ] **1.2.2** [REVIEW-A] Ревью +- [ ] **1.2.3** [PROG-A] Исправления по ревью + +### 1.3 — Листинг ассетов (asset_count / get_asset) + +- [ ] **1.3.1** [PROG-A] Тесты + реализация: + - Итерация объектов, фильтрация экспортируемых типов + - Маппинг в `asset_info` с именем + расширение экспорта + - Дедупликация имён (суффикс `_N` при коллизиях) + - Вложенные .assets → плоский список +- [ ] **1.3.2** [REVIEW-A] Ревью +- [ ] **1.3.3** [PROG-A] Исправления по ревью + +### 1.4 — Извлечение с конвертацией (extract) + +- [ ] **1.4.1** [PROG-A] Тесты + реализация: + - По типу объекта: Texture2D → PNG, Sprite → PNG, AudioClip → WAV, TextAsset → as-is, Font → TTF, Shader → текст, MonoBehaviour → JSON (если есть type tree), прочее → сырые байты + - Запись чанками с вызовом progress callback + - Прерывание по callback returning false +- [ ] **1.4.2** [REVIEW-A] Ревью — особое внимание: memory pressure на больших текстурах, disposal потоков, exception safety +- [ ] **1.4.3** [PROG-A] Исправления по ревью + +### 1.5 — Деструктор / очистка ресурсов + +- [ ] **1.5.1** [PROG-A] Тесты + реализация: dispose gcroot, освобождение AssetsManager, move-семантика +- [ ] **1.5.2** [REVIEW-A] Ревью +- [ ] **1.5.3** [PROG-A] Исправления по ревью + +--- + +## Фаза 2: Слой архива (чистый C++) + +Может идти **параллельно** с фазой 1 после финализации `bridge.h`. +Юнит-тесты через мок bridge. + +### 2.1 — Мок + Archive Wrapper + +- [ ] **2.1.1** [PROG-B] Мок `unity::bundle` для юнит-тестов (настраиваемый список файлов, поведение extract, инъекция ошибок) +- [ ] **2.1.2** [PROG-B] Тесты + реализация `unity_archive` в `unity_archive.h/cpp`: + - Обёртка над `unity::bundle` + - Аналогичный паттерн `archive::archive`, но без зависимости от `extractor.h` + - Конвертация `unity::asset_info` → внутренняя структура файла + - Трансляция исключений, нормализация путей +- [ ] **2.1.3** [REVIEW-B] Ревью +- [ ] **2.1.4** [PROG-B] Исправления по ревью + +--- + +## Фаза 3: Точки входа DLL (Observer API) + +Зависит от стабильного интерфейса фазы 2. Тесты можно писать параллельно с фазами 1+2. + +### 3.1 — dll.cpp для Unity-модуля + +- [ ] **3.1.1** [PROG-C] Тесты для всех функций Observer API (`OpenStorage`, `CloseStorage`, `PrepareFiles`, `GetItem`, `ExtractItem`, `LoadSubModule`/`UnloadSubModule`) +- [ ] **3.1.2** [PROG-C] Реализация `dll.cpp` по паттерну существующих модулей +- [ ] **3.1.3** [PROG-C] Создать `unity.def` и `observer_user.ini` (расширения: `*.assets`, `*.unity3d`, `*.bundle`, `*.ab`) +- [ ] **3.1.4** [REVIEW-C] Ревью — особое внимание: исключения не должны вылетать из extern "C" +- [ ] **3.1.5** [PROG-C] Исправления по ревью + +--- + +## Фаза 4: Интеграционное тестирование + +Зависит от фаз 1, 2, 3. + +### 4.1 — End-to-End тесты + +- [ ] **4.1.1** [PROG-D] Подготовить тестовые Unity-файлы (< 1 MB): + - AssetBundle с Texture2D (LZ4) + - AssetBundle с AudioClip + - Raw .assets с TextAsset + - AssetBundle со смешанными типами + - Битый/пустой файл для проверки ошибок +- [ ] **4.1.2** [PROG-D] Написать `src/tests/unity.cpp` — Catch2 тесты через `test::observer`: + - Загрузка unity.so, открытие файлов, листинг, извлечение, проверка хешей +- [ ] **4.1.3** [REVIEW-D] Ревью тестов +- [ ] **4.1.4** [PROG-D] Исправления по ревью + +### 4.2 — Сосуществование и смоук-тесты + +- [ ] **4.2.1** [PROG-D] Проверить одновременную загрузку unity.so с другими модулями (renpy, rpgmaker, zanzarah, garbro) — отсутствие конфликтов CLR +- [ ] **4.2.2** [PROG-D] Смоук-тесты на реальных играх: Unity 5.x, Unity 2019–2021, Unity 2022+ — проверить что текстуры рендерятся, аудио воспроизводится + +--- + +## Фаза 5: Пакетирование + +Может начинаться параллельно с фазой 4. + +- [ ] **5.1** [PROG-B] CPack-правила для модуля unity: + - `unity-{DATE}-{ARCH}-dll.zip`: `unity.so`, `observer_user.ini`, `unity/AssetStudio.dll`, `unity/Texture2DDecoderNative.dll`, `licenses/` + - PDB-пакет отдельно + - x86 и x64 (AssetStudio.dll общая, нативные DLL платформо-зависимые) +- [ ] **5.2** [REVIEW-B] Ревью пакетирования +- [ ] **5.3** [PROG-B] Исправления по ревью + +--- + +## Фаза 6: Финальное ревью + +- [ ] **6.1** [REVIEW-ALL] Полное ревью всего кода: стиль, утечки памяти на границе managed/native, исключения, покрытие тестами +- [ ] **6.2** [PROG-ALL] Исправления +- [ ] **6.3** Полный прогон тестов (все модули включая unity), x86 и x64 +- [ ] **6.4** Сборка релизных пакетов + +--- + +## Карта параллелизма + +``` +Фаза 0.1 (интерфейс bridge.h) + │ + ├───────────────────────┐ + ▼ ▼ +Фаза 0.2 Фаза 0.3 +(скелет CMake) (сборка AssetStudio +[PROG-B] + ILRepack) + [PROG-B] + │ │ + ├───────────────────────┘ + │ + ├──────────────────┬──────────────────┐ + ▼ ▼ ▼ +Фаза 1 Фаза 2 Фаза 3 (тесты) +(bridge impl) (слой архива) (dll.cpp тесты) +[PROG-A] [PROG-B] [PROG-C] + │ │ │ + └──────────────────┴──────────────────┘ + │ + ▼ + Фаза 3 (реализация) + │ + ▼ + Фаза 4 + (интеграция) + [PROG-D] + │ + ▼ + Фаза 5 + (пакетирование) + │ + ▼ + Фаза 6 + (финальное ревью) +``` + +## Роли агентов + +| Роль | Зона ответственности | +|------|---------------------| +| **PROG-A** | Bridge layer (C++/CLI) — `bridge.h`, `bridge.cpp`, тесты bridge | +| **PROG-B** | Система сборки + AssetStudio build + ILRepack + слой архива + мок + пакетирование | +| **PROG-C** | Точки входа DLL — `dll.cpp`, `unity.def`, `observer_user.ini`, тесты Observer API | +| **PROG-D** | Интеграционные тесты, смоук-тесты, тестовые данные | +| **REVIEW-*** | Ревью соответствующих PROG-агентов | +| **REVIEW-ALL** | Финальное сквозное ревью | + +## Технические заметки + +- `/clr` несовместим с `/EHsc` — для `bridge.cpp` использовать `/EHa` +- `/clr` несовместим со статическим CRT (`/MT`) — модуль unity целиком на `/MD`; остальные модули (renpy, rpgmaker, zanzarah) остаются на `/MT` +- `gcroot` — способ хранения managed-ссылок в нативных классах (внутри pimpl) +- Если модуль garbro тоже загружен — оба делят один CLR (оба под .NET Framework 4.7.2), конфликтов нет +- `AssetStudio.dll` — Any CPU, работает и в x86 и в x64 CLR +- `Texture2DDecoderNative.dll` — платформо-зависимая, должна соответствовать архитектуре unity.so +- **Резолв P/Invoke**: `unity::init()` должен вызвать `AddDllDirectory()` для подпапки `unity/`, чтобы P/Invoke из AssetStudio нашёл Texture2DDecoderNative.dll +- **Давление на память**: декодирование Texture2D может выделять большие RGBA-буферы (4096×4096 = 64 MB). Observer вызывает ExtractItem последовательно, так что это нормально. +- **AudioClip / FMOD**: часть аудио в Unity хранится в FSB5. AssetStudio (aelurum) включает поддержку FMOD — проверить что работает в net472 сборке. Если нужна нативная FMOD-библиотека, добавить в подпапку `unity/`. +- **Сжатие AssetBundle**: Unity использует LZMA (старые) и LZ4/LZ4HC (новые). AssetStudio обрабатывает оба прозрачно. +- **Type trees**: некоторые .assets файлы не содержат встроенных type trees. AssetStudio включает фоллбэк type trees для популярных версий Unity — убедиться что они попадают в ILRepack-merged сборку. +- Тестовые Unity-файлы < 1 MB, коммитятся в директорию тестовых данных. +- Обновление AssetStudio: `cd extern/AssetStudio && git pull && cd ../.. && git add extern/AssetStudio && git commit` From 294b584bc88cb1b49932e62eff9c020b936d889b Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sat, 1 Aug 2026 22:14:33 +1000 Subject: [PATCH 02/17] chore: checkpoint MSBuild verification and DAG experiments --- .clang-format | 18 + .clang-tidy | 13 + .github/workflows/main.yml | 674 ++++++- .github/workflows/mutation.yml | 120 ++ .gitignore | 4 +- .idea/codeStyles/codeStyleConfig.xml | 5 - .idea/copilot.data.migration.agent.xml | 6 - .idea/dictionaries/project.xml | 40 - .idea/editor.xml | 343 ---- .idea/misc.xml | 7 - .idea/modules.xml | 8 - .../Copy_x64_debug_to_Far.xml | 14 - .../Copy_x64_release_to_Far.xml | 14 - .../Copy_x86_debug_to_Far.xml | 14 - .../Copy_x86_release_to_Far.xml | 14 - .idea/runConfigurations/Far_x64_debug.xml | 11 - .idea/runConfigurations/Far_x64_release.xml | 11 - .idea/runConfigurations/Far_x86_debug.xml | 11 - .idea/runConfigurations/Far_x86_release.xml | 11 - .idea/runConfigurations/Run_Tests.xml | 8 - .idea/vcs.xml | 6 - AGENTS.md | 93 + CLAUDE.md | 56 - CMakeLists.txt | 96 - CMakePresets.json | 67 - README.md | 23 +- build.cmd | 2 + build.ps1 | 6 + build/GRAPH.md | 167 ++ build/ObserverConfiguration.props | 15 + build/ObserverFuzz.props | 14 + build/ObserverModules.proj | 131 ++ build/ObserverNativeAnalysis.ruleset | 6 + build/ObserverProject.props | 85 + build/ObserverProjectConfigurations.props | 19 + build/PSScriptAnalyzerSettings.psd1 | 6 + build/build.ps1 | 1575 +++++++++++++++++ build/dynamic_graph.py | 661 +++++++ build/graph_driver.py | 1063 +++++++++++ build/graph_profiles.json | 190 ++ build/ixdag/__init__.py | 2 + build/ixdag/execute.py | 366 ++++ build/ixdag/graph.py | 488 +++++ build/ixdag/templates/node.json.j2 | 1 + build/lib/analysis-reporting.ps1 | 179 ++ build/lib/common.ps1 | 106 ++ build/lib/dynamic-graph-leaves.ps1 | 454 +++++ build/lib/graph-leaves.ps1 | 476 +++++ build/lib/package-manifest.ps1 | 213 +++ build/lib/package-smoke.ps1 | 177 ++ build/lib/packaging.ps1 | 118 ++ build/lib/verify-routing.ps1 | 95 + build/lib/verify.ps1 | 75 + build/mutation/README.md | 36 + build/mutation/mull.yml | 17 + build/mutation/run.sh | 79 + build/mutation/validate_report.py | 86 + build/native_graph.py | 458 +++++ build/projects/fuzz-pickle.vcxproj | 36 + build/projects/fuzz-renpy.vcxproj | 45 + build/projects/fuzz-rpgmaker.vcxproj | 37 + build/projects/fuzz-zanzarah.vcxproj | 37 + build/projects/leak-probe.vcxproj | 51 + build/projects/renpy.vcxproj | 48 + build/projects/rpgmaker.vcxproj | 43 + build/projects/tests.vcxproj | 66 + build/projects/zanzarah.vcxproj | 43 + build/run-msbuild.cmd | 12 + build/tests/analysis-reporting.Tests.ps1 | 123 ++ .../tests/build-entrypoint-contract.Tests.ps1 | 295 +++ build/tests/ci-reporting-contract.Tests.ps1 | 123 ++ build/tests/compiler-analysis-graph.Tests.ps1 | 253 +++ build/tests/dynamic-graph-leaves.Tests.ps1 | 193 ++ build/tests/graph-leaves.Tests.ps1 | 463 +++++ build/tests/leak-release-contract.Tests.ps1 | 86 + build/tests/mutation-ci-contract.Tests.ps1 | 128 ++ build/tests/package-manifest.Tests.ps1 | 291 +++ build/tests/package-smoke-contract.Tests.ps1 | 154 ++ .../security-mitigation-contract.Tests.ps1 | 36 + build/tests/test_dynamic_graph.py | 273 +++ build/tests/test_graph_driver.py | 1023 +++++++++++ build/tests/test_ixdag_execute.py | 274 +++ build/tests/test_ixdag_graph.py | 189 ++ build/tests/test_mutation_report.py | 92 + build/tests/test_native_graph.py | 223 +++ build/tests/test_verify_graph.py | 127 ++ .../verify-orchestration-contract.Tests.ps1 | 98 + build/tests/verify-routing.Tests.ps1 | 65 + .../observer-arm64-windows-static.cmake | 5 + .../observer-x64-windows-static-asan.cmake | 6 + .../observer-x64-windows-static.cmake | 5 + .../observer-x86-windows-static-asan.cmake | 6 + .../observer-x86-windows-static.cmake | 5 + build/verify_graph.py | 284 +++ copy_dlls.cmd | 6 - docs/README.md | 7 +- docs/autonomous-work-log.md | 96 + docs/build-system.md | 273 +++ docs/code-deep-dive.md | 2 +- docs/critical-software-methodology.md | 168 ++ docs/current-status.md | 148 ++ licenses/IX.txt | 22 + licenses/zstr.txt | 21 - src/api.h | 34 +- src/archive.cpp | 55 +- src/archive.h | 20 +- src/core/archive_limits.h | 13 + src/core/compression/zlib_codec.cpp | 92 + src/core/compression/zlib_codec.h | 18 + src/core/io/bounded_stream.cpp | 77 + src/core/io/bounded_stream.h | 43 + src/dll.cpp | 54 +- src/fuzz/archive.cpp | 51 + .../pickle/external-catch-canvas-index.hex | 1 + .../corpus/pickle/invalid-empty-int.pickle | 1 + .../corpus/pickle/invalid-mark-position.hex | 1 + src/fuzz/corpus/pickle/none.pickle | 1 + .../external-catch-canvas-nao-prefix.hex | 1 + src/fuzz/corpus/renpy/valid-rpa2.hex | 1 + .../rpgmaker/declared-name-overflow.hex | 1 + .../rpgmaker/external-smallest-entry.hex | 1 + src/fuzz/corpus/rpgmaker/valid-rgss3a.hex | 1 + .../declared-entry-count-overflow.hex | 1 + .../zanzarah/declared-path-overflow.hex | 1 + .../zanzarah/external-smallest-entry.hex | 1 + src/fuzz/corpus/zanzarah/valid-pak.hex | 1 + src/fuzz/pickle.cpp | 29 + src/modules/extractor.h | 21 +- src/modules/renpy/pickle.cpp | 696 ++++---- src/modules/renpy/pickle.h | 50 +- src/modules/renpy/renpy.cpp | 89 +- src/modules/rpgmaker/rpgmaker.cpp | 61 +- src/modules/zanzarah/zanzarah.cpp | 76 +- src/tests/framework/observer.cpp | 545 +++++- src/tests/framework/observer.h | 20 +- src/tests/framework/testcase.cpp | 91 +- src/tests/framework/testcase.h | 13 +- src/tests/integration/archives.cpp | 68 + src/tests/leaks/probe.cpp | 681 +++++++ src/tests/main.cpp | 22 + src/tests/mutation/main.cpp | 6 + src/tests/renpy.cpp | 56 +- src/tests/rpgmaker.cpp | 36 +- src/tests/support/archive_fixtures.cpp | 366 ++++ src/tests/support/archive_fixtures.h | 72 + src/tests/support/zlib_fixture.cpp | 27 + src/tests/support/zlib_fixture.h | 10 + src/tests/unit/bounded_stream.cpp | 220 +++ src/tests/unit/pickle.cpp | 249 +++ src/tests/unit/zlib.cpp | 118 ++ src/tests/zanzarah.cpp | 12 +- vcpkg.json | 44 +- 152 files changed, 17643 insertions(+), 1539 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .github/workflows/mutation.yml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/copilot.data.migration.agent.xml delete mode 100644 .idea/dictionaries/project.xml delete mode 100644 .idea/editor.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/Copy_x64_debug_to_Far.xml delete mode 100644 .idea/runConfigurations/Copy_x64_release_to_Far.xml delete mode 100644 .idea/runConfigurations/Copy_x86_debug_to_Far.xml delete mode 100644 .idea/runConfigurations/Copy_x86_release_to_Far.xml delete mode 100644 .idea/runConfigurations/Far_x64_debug.xml delete mode 100644 .idea/runConfigurations/Far_x64_release.xml delete mode 100644 .idea/runConfigurations/Far_x86_debug.xml delete mode 100644 .idea/runConfigurations/Far_x86_release.xml delete mode 100644 .idea/runConfigurations/Run_Tests.xml delete mode 100644 .idea/vcs.xml create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 CMakeLists.txt delete mode 100644 CMakePresets.json create mode 100644 build.cmd create mode 100644 build.ps1 create mode 100644 build/GRAPH.md create mode 100644 build/ObserverConfiguration.props create mode 100644 build/ObserverFuzz.props create mode 100644 build/ObserverModules.proj create mode 100644 build/ObserverNativeAnalysis.ruleset create mode 100644 build/ObserverProject.props create mode 100644 build/ObserverProjectConfigurations.props create mode 100644 build/PSScriptAnalyzerSettings.psd1 create mode 100644 build/build.ps1 create mode 100644 build/dynamic_graph.py create mode 100644 build/graph_driver.py create mode 100644 build/graph_profiles.json create mode 100644 build/ixdag/__init__.py create mode 100644 build/ixdag/execute.py create mode 100644 build/ixdag/graph.py create mode 100644 build/ixdag/templates/node.json.j2 create mode 100644 build/lib/analysis-reporting.ps1 create mode 100644 build/lib/common.ps1 create mode 100644 build/lib/dynamic-graph-leaves.ps1 create mode 100644 build/lib/graph-leaves.ps1 create mode 100644 build/lib/package-manifest.ps1 create mode 100644 build/lib/package-smoke.ps1 create mode 100644 build/lib/packaging.ps1 create mode 100644 build/lib/verify-routing.ps1 create mode 100644 build/lib/verify.ps1 create mode 100644 build/mutation/README.md create mode 100644 build/mutation/mull.yml create mode 100644 build/mutation/run.sh create mode 100644 build/mutation/validate_report.py create mode 100644 build/native_graph.py create mode 100644 build/projects/fuzz-pickle.vcxproj create mode 100644 build/projects/fuzz-renpy.vcxproj create mode 100644 build/projects/fuzz-rpgmaker.vcxproj create mode 100644 build/projects/fuzz-zanzarah.vcxproj create mode 100644 build/projects/leak-probe.vcxproj create mode 100644 build/projects/renpy.vcxproj create mode 100644 build/projects/rpgmaker.vcxproj create mode 100644 build/projects/tests.vcxproj create mode 100644 build/projects/zanzarah.vcxproj create mode 100644 build/run-msbuild.cmd create mode 100644 build/tests/analysis-reporting.Tests.ps1 create mode 100644 build/tests/build-entrypoint-contract.Tests.ps1 create mode 100644 build/tests/ci-reporting-contract.Tests.ps1 create mode 100644 build/tests/compiler-analysis-graph.Tests.ps1 create mode 100644 build/tests/dynamic-graph-leaves.Tests.ps1 create mode 100644 build/tests/graph-leaves.Tests.ps1 create mode 100644 build/tests/leak-release-contract.Tests.ps1 create mode 100644 build/tests/mutation-ci-contract.Tests.ps1 create mode 100644 build/tests/package-manifest.Tests.ps1 create mode 100644 build/tests/package-smoke-contract.Tests.ps1 create mode 100644 build/tests/security-mitigation-contract.Tests.ps1 create mode 100644 build/tests/test_dynamic_graph.py create mode 100644 build/tests/test_graph_driver.py create mode 100644 build/tests/test_ixdag_execute.py create mode 100644 build/tests/test_ixdag_graph.py create mode 100644 build/tests/test_mutation_report.py create mode 100644 build/tests/test_native_graph.py create mode 100644 build/tests/test_verify_graph.py create mode 100644 build/tests/verify-orchestration-contract.Tests.ps1 create mode 100644 build/tests/verify-routing.Tests.ps1 create mode 100644 build/vcpkg/triplets/observer-arm64-windows-static.cmake create mode 100644 build/vcpkg/triplets/observer-x64-windows-static-asan.cmake create mode 100644 build/vcpkg/triplets/observer-x64-windows-static.cmake create mode 100644 build/vcpkg/triplets/observer-x86-windows-static-asan.cmake create mode 100644 build/vcpkg/triplets/observer-x86-windows-static.cmake create mode 100644 build/verify_graph.py delete mode 100644 copy_dlls.cmd create mode 100644 docs/autonomous-work-log.md create mode 100644 docs/build-system.md create mode 100644 docs/critical-software-methodology.md create mode 100644 docs/current-status.md create mode 100644 licenses/IX.txt delete mode 100644 licenses/zstr.txt create mode 100644 src/core/archive_limits.h create mode 100644 src/core/compression/zlib_codec.cpp create mode 100644 src/core/compression/zlib_codec.h create mode 100644 src/core/io/bounded_stream.cpp create mode 100644 src/core/io/bounded_stream.h create mode 100644 src/fuzz/archive.cpp create mode 100644 src/fuzz/corpus/pickle/external-catch-canvas-index.hex create mode 100644 src/fuzz/corpus/pickle/invalid-empty-int.pickle create mode 100644 src/fuzz/corpus/pickle/invalid-mark-position.hex create mode 100644 src/fuzz/corpus/pickle/none.pickle create mode 100644 src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex create mode 100644 src/fuzz/corpus/renpy/valid-rpa2.hex create mode 100644 src/fuzz/corpus/rpgmaker/declared-name-overflow.hex create mode 100644 src/fuzz/corpus/rpgmaker/external-smallest-entry.hex create mode 100644 src/fuzz/corpus/rpgmaker/valid-rgss3a.hex create mode 100644 src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex create mode 100644 src/fuzz/corpus/zanzarah/declared-path-overflow.hex create mode 100644 src/fuzz/corpus/zanzarah/external-smallest-entry.hex create mode 100644 src/fuzz/corpus/zanzarah/valid-pak.hex create mode 100644 src/fuzz/pickle.cpp create mode 100644 src/tests/integration/archives.cpp create mode 100644 src/tests/leaks/probe.cpp create mode 100644 src/tests/main.cpp create mode 100644 src/tests/mutation/main.cpp create mode 100644 src/tests/support/archive_fixtures.cpp create mode 100644 src/tests/support/archive_fixtures.h create mode 100644 src/tests/support/zlib_fixture.cpp create mode 100644 src/tests/support/zlib_fixture.h create mode 100644 src/tests/unit/bounded_stream.cpp create mode 100644 src/tests/unit/pickle.cpp create mode 100644 src/tests/unit/zlib.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..025bf3b --- /dev/null +++ b/.clang-format @@ -0,0 +1,18 @@ +BasedOnStyle: Microsoft +Standard: c++20 +ColumnLimit: 120 +IndentWidth: 4 +NamespaceIndentation: All +TabWidth: 4 +UseTab: Never +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterControlStatement: Never + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + BeforeCatch: false + BeforeElse: false +SortIncludes: CaseSensitive diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..92a0aa8 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,13 @@ +--- +Checks: > + -*, + clang-analyzer-*, + bugprone-*, + performance-*, + portability-*, + -bugprone-easily-swappable-parameters +WarningsAsErrors: '*' +HeaderFilterRegex: '.*[\\/]src[\\/].*' +SystemHeaders: false +FormatStyle: file +... diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fa796e8..2c69789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,97 +2,655 @@ name: CI on: push: - branches: [ "master" ] + branches: [master] pull_request: - branches: [ "master" ] + branches: [master] + schedule: + - cron: "17 18 * * 6" + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: - contents: write + contents: read env: - VCPKG_DEFAULT_BINARY_CACHE: "C:/vcpkg-binary-cache" + VCPKG_ROOT: C:\vcpkg + VCPKG_DEFAULT_BINARY_CACHE: C:\vcpkg-binary-cache jobs: - build: + source-quality: + name: Source quality + runs-on: windows-2022 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + + - name: Install source-analysis tools + shell: pwsh + run: | + choco install cppcheck --version=2.19.0 --yes --no-progress + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force + + - name: Run formatting and source analyzers + id: source_checks + continue-on-error: true + shell: pwsh + run: ./build.ps1 source-checks -Arch all + + - name: Upload Cppcheck x86 SARIF + if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-x86.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/cppcheck/cppcheck-x86.sarif + category: cppcheck/x86 + + - name: Upload Cppcheck x64 SARIF + if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-x64.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/cppcheck/cppcheck-x64.sarif + category: cppcheck/x64 + + - name: Upload Cppcheck ARM64 SARIF + if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-arm64.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/cppcheck/cppcheck-arm64.sarif + category: cppcheck/arm64 + + - name: Upload PSScriptAnalyzer SARIF + if: always() && hashFiles('.artifacts/reports/psscriptanalyzer/psscriptanalyzer.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/psscriptanalyzer/psscriptanalyzer.sarif + category: psscriptanalyzer + + - name: Archive source-analysis reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: source-analysis-reports + path: .artifacts/reports + if-no-files-found: ignore + + - name: Enforce source-quality gate + if: steps.source_checks.outcome == 'failure' + shell: pwsh + run: throw 'Source-quality checks failed.' + + tests: + name: MSVC tests (${{ matrix.arch }}, ${{ matrix.config }}) runs-on: windows-2022 strategy: + fail-fast: false matrix: - arch: [ x64, x86 ] + arch: [x86, x64] + config: [Debug, Release] + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run deterministic tests + id: tests + continue-on-error: true + shell: pwsh + run: ./build.ps1 test -Arch ${{ matrix.arch }} -Config ${{ matrix.config }} + + - name: Publish test report + if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@v3 + with: + name: MSVC tests (${{ matrix.arch }}, ${{ matrix.config }}) + path: .artifacts/reports/tests/*.xml + reporter: java-junit + fail-on-error: false + + - name: Archive test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-reports-${{ matrix.arch }}-${{ matrix.config }} + path: .artifacts/reports/tests + if-no-files-found: ignore + + - name: Enforce test gate + if: steps.tests.outcome == 'failure' + shell: pwsh + run: throw 'MSVC tests failed.' + + tests-arm64: + name: MSVC tests (ARM64, ${{ matrix.config }}) + runs-on: windows-11-arm + strategy: + fail-fast: false + matrix: + config: [Debug, Release] + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-arm64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run native ARM64 deterministic tests + id: tests + continue-on-error: true + shell: pwsh + run: ./build.ps1 test -Arch arm64 -Config ${{ matrix.config }} + + - name: Publish ARM64 test report + if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@v3 + with: + name: MSVC tests (ARM64, ${{ matrix.config }}) + path: .artifacts/reports/tests/*.xml + reporter: java-junit + fail-on-error: false + + - name: Archive ARM64 test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-reports-arm64-${{ matrix.config }} + path: .artifacts/reports/tests + if-no-files-found: ignore + + - name: Enforce ARM64 test gate + if: steps.tests.outcome == 'failure' + shell: pwsh + run: throw 'Native ARM64 MSVC tests failed.' + + leaks: + name: UMDH leak gate (x64) + runs-on: windows-2022 + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Setup Developer Command Prompt - uses: ilammy/msvc-dev-cmd@v1 + - name: Cache vcpkg binaries + uses: actions/cache@v5 with: - arch: ${{ matrix.arch }} + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - name: Get project vcpkg baseline + - name: Ensure Windows Debugging Tools are available shell: pwsh run: | - $baseline = (Get-Content -Path vcpkg.json | ConvertFrom-Json).'builtin-baseline' - echo "VCPKG_BASELINE=$baseline" >> $env:GITHUB_ENV + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + $umdh = Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64\umdh.exe' + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + $installer = Join-Path $env:RUNNER_TEMP 'winsdksetup.exe' + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2349110' -OutFile $installer + $signature = Get-AuthenticodeSignature -LiteralPath $installer + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { + throw "Windows SDK installer signature validation failed: $($signature.Status)" + } + $process = Start-Process -FilePath $installer -ArgumentList @( + '/features', 'OptionId.WindowsDesktopDebuggers', + '/quiet', '/norestart', '/ceip', 'off' + ) -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Windows Debugging Tools installation failed with exit code $($process.ExitCode)." + } + } + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + throw "UMDH was not found after Windows Debugging Tools setup: $umdh" + } - - name: Cache vcpkg - uses: actions/cache@v4 + - name: Diagnose leak-test toolchain + shell: pwsh + run: ./build.ps1 doctor -Arch x64 + + - name: Run Release UMDH operation, failure, metadata, and DLL-lifecycle leak tests + id: leaks + continue-on-error: true + shell: pwsh + run: ./build.ps1 test-leaks -Arch x64 + + - name: Archive UMDH leak reports + if: always() + uses: actions/upload-artifact@v7 with: - key: vcpkg-${{ matrix.arch }}-${{ hashFiles('vcpkg.json') }} + name: umdh-leak-reports-x64 + path: .artifacts/reports/leaks/x64 + if-no-files-found: ignore + + - name: Enforce leak gate + if: steps.leaks.outcome == 'failure' + shell: pwsh + run: throw 'UMDH detected sustained heap growth or the leak test failed.' + + coverage: + name: Branch coverage + runs-on: windows-2022 + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Enforce 100 percent source coverage + id: coverage + continue-on-error: true + shell: pwsh + run: ./build.ps1 test-coverage -Arch x64 -CoverageThreshold 100 + + - name: Add coverage summary + if: always() && hashFiles('.artifacts/coverage/x64/coverage.json') != '' + shell: pwsh + run: | + $totals = (Get-Content -Raw .artifacts/coverage/x64/coverage.json | ConvertFrom-Json).data[0].totals + @( + '## LLVM source coverage' + '' + '| Metric | Covered | Total | Percent |' + '|---|---:|---:|---:|' + "| Branches | $($totals.branches.covered) | $($totals.branches.count) | $($totals.branches.percent.ToString('F2'))% |" + "| Functions | $($totals.functions.covered) | $($totals.functions.count) | $($totals.functions.percent.ToString('F2'))% |" + "| Lines | $($totals.lines.covered) | $($totals.lines.count) | $($totals.lines.percent.ToString('F2'))% |" + "| Regions | $($totals.regions.covered) | $($totals.regions.count) | $($totals.regions.percent.ToString('F2'))% |" + ) | Add-Content -Path $env:GITHUB_STEP_SUMMARY + + - name: Publish coverage test report + if: always() && hashFiles('.artifacts/reports/tests/tests-x64-coverage.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@v3 + with: + name: Coverage tests + path: .artifacts/reports/tests/tests-x64-coverage.xml + reporter: java-junit + fail-on-error: false + + - name: Archive coverage reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-reports path: | - ${{env.VCPKG_DEFAULT_BINARY_CACHE}} + .artifacts/coverage/x64/coverage.json + .artifacts/coverage/x64/coverage.lcov + .artifacts/reports/tests/tests-x64-coverage.xml + if-no-files-found: ignore + + - name: Enforce coverage gate + if: steps.coverage.outcome == 'failure' + shell: pwsh + run: throw 'Coverage is below the required threshold or the coverage run failed.' + + compiler-analysis: + name: Compiler analysis (${{ matrix.arch }}) + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + arch: [x86, x64, arm64] + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - name: Setup vcpkg + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run MSVC analysis and clang-tidy + id: analysis + continue-on-error: true + shell: pwsh + run: ./build.ps1 compiler-analysis -Arch ${{ matrix.arch }} + + - name: Upload MSVC SARIF + if: always() && hashFiles('.artifacts/reports/msvc/${{ matrix.arch }}/*.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/msvc/${{ matrix.arch }} + category: msvc-analyze/${{ matrix.arch }} + + - name: Upload clang-tidy SARIF + if: always() && hashFiles('.artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif + category: clang-tidy/${{ matrix.arch }} + + - name: Archive compiler-analysis reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: compiler-analysis-${{ matrix.arch }} + path: | + .artifacts/reports/msvc/${{ matrix.arch }} + .artifacts/reports/clang-tidy/${{ matrix.arch }} + if-no-files-found: ignore + + - name: Enforce compiler-analysis gate + if: steps.analysis.outcome == 'failure' + shell: pwsh + run: throw 'Compiler analysis failed.' + + asan: + name: MSVC ASan (${{ matrix.arch }}) + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + arch: [x86, x64] + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-asan-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run MSVC AddressSanitizer tests + id: asan + continue-on-error: true + shell: pwsh + run: ./build.ps1 test-asan -Arch ${{ matrix.arch }} + + - name: Publish ASan test report + if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@v3 + with: + name: MSVC ASan (${{ matrix.arch }}) + path: .artifacts/reports/tests/*.xml + reporter: java-junit + fail-on-error: false + + - name: Archive ASan reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: asan-reports-${{ matrix.arch }} + path: .artifacts/reports/tests + if-no-files-found: ignore + + - name: Enforce ASan gate + if: steps.asan.outcome == 'failure' + shell: pwsh + run: throw 'MSVC ASan failed.' + + ubsan: + name: clang-cl UBSan + runs-on: windows-2022 + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run UndefinedBehaviorSanitizer tests + id: ubsan + continue-on-error: true + shell: pwsh + run: ./build.ps1 test-ubsan -Arch x64 + + - name: Publish UBSan test report + if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@v3 + with: + name: clang-cl UBSan + path: .artifacts/reports/tests/*.xml + reporter: java-junit + fail-on-error: false + + - name: Archive UBSan reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: ubsan-reports + path: .artifacts/reports/tests + if-no-files-found: ignore + + - name: Enforce UBSan gate + if: steps.ubsan.outcome == 'failure' + shell: pwsh + run: throw 'clang-cl UBSan failed.' + + fuzz: + name: Fuzz smoke test + runs-on: windows-2022 + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-x64-asan-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Run libFuzzer + id: fuzz + continue-on-error: true + shell: pwsh + run: | + $secondsPerTarget = if ('${{ github.event_name }}' -eq 'schedule') { 1800 } else { 30 } + ./build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds $secondsPerTarget + + - name: Archive fuzz corpus and crashes + if: always() + uses: actions/upload-artifact@v7 + with: + name: fuzz-results + path: .artifacts/fuzz + if-no-files-found: ignore + + - name: Enforce fuzz gate + if: steps.fuzz.outcome == 'failure' + shell: pwsh + run: throw 'Fuzz smoke test found a failure.' + + codeql: + name: CodeQL C++ + runs-on: windows-2022 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare dependencies before tracing + shell: pwsh run: | - New-Item -ItemType Directory -Path C:/my-vcpkg - Set-Location -Path C:/my-vcpkg - git init - git remote add --no-tags origin https://github.com/microsoft/vcpkg.git - git fetch --depth 1 --no-write-fetch-head origin ${{env.VCPKG_BASELINE}} - git branch master ${{env.VCPKG_BASELINE}} - git checkout - ./bootstrap-vcpkg.bat - New-Item -ItemType Directory -Path ${{env.VCPKG_DEFAULT_BINARY_CACHE}} -Force - echo "VCPKG_ROOT=C:/my-vcpkg" >> $env:GITHUB_ENV - - - name: Configure CMake - run: cmake --preset ${{ matrix.arch }}-release - - - name: Build - run: cmake --build ${{github.workspace}}/build/${{ matrix.arch }}-release - - - name: Pack + New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + ./build.ps1 restore -Arch x64 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: c-cpp + build-mode: manual + + - name: Build traced MSVC binaries + shell: pwsh + run: ./build.ps1 build -Arch x64 -Config Release + + - name: Analyze and upload CodeQL SARIF + uses: github/codeql-action/analyze@v4 + with: + category: codeql/c-cpp + output: .artifacts/reports/codeql/raw + post-processed-sarif-path: .artifacts/reports/codeql/uploaded + + - name: Archive CodeQL SARIF + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-sarif + path: .artifacts/reports/codeql + if-no-files-found: ignore + + package: + name: Release package (${{ matrix.arch }}) + needs: [tests, tests-arm64, leaks] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86 + runner: windows-2022 + - arch: x64 + runner: windows-2022 + - arch: arm64 + runner: windows-11-arm + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + + - name: Prepare vcpkg cache + shell: pwsh + run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + + - name: Install BinSkim + shell: pwsh run: | - cd ${{github.workspace}}/build/${{ matrix.arch }}-release - cpack --config CPackConfig.cmake -C RelWithDebInfo + dotnet tool install --global Microsoft.CodeAnalysis.BinSkim --version 4.4.9.11 + (Join-Path $env:USERPROFILE '.dotnet\tools') | Add-Content -Path $env:GITHUB_PATH - - name: Upload artifacts - uses: actions/upload-artifact@v4 + - name: Build, audit, and package + id: package + continue-on-error: true + shell: pwsh + run: ./build.ps1 package -Arch ${{ matrix.arch }} + + - name: Upload BinSkim SARIF + if: always() && hashFiles('.artifacts/audit/binskim-${{ matrix.arch }}.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: .artifacts/audit/binskim-${{ matrix.arch }}.sarif + category: binskim/${{ matrix.arch }} + + - name: Archive release packages + if: always() + uses: actions/upload-artifact@v7 + with: + name: packages-${{ matrix.arch }} + path: .artifacts/packages/*.zip + if-no-files-found: error + + - name: Archive binary-audit report + if: always() + uses: actions/upload-artifact@v7 with: - name: observer-modules-${{ matrix.arch }} - path: ${{github.workspace}}/build/${{ matrix.arch }}-release/*.zip + name: binary-audit-${{ matrix.arch }} + path: .artifacts/audit/binskim-${{ matrix.arch }}.sarif + if-no-files-found: ignore + + - name: Enforce package gate + if: steps.package.outcome == 'failure' + shell: pwsh + run: throw 'Release build, binary audit, or packaging failed.' release: - needs: build - runs-on: ubuntu-latest + name: Publish release + needs: [source-quality, tests, tests-arm64, leaks, coverage, compiler-analysis, asan, ubsan, fuzz, codeql, package] if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 + - name: Download release packages + uses: actions/download-artifact@v8 with: + pattern: packages-* merge-multiple: true - path: ./artifacts + path: release-assets - name: Generate release tag id: tag - run: echo "tag=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + shell: bash + run: echo "tag=release-$(date -u +'%Y%m%d-%H%M%S')" >> "$GITHUB_OUTPUT" - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Create GitHub release + uses: softprops/action-gh-release@v3 with: - tag_name: release-${{ steps.tag.outputs.tag }} - name: 'Release ${{ steps.tag.outputs.tag }}' - body: | - Automated release from master branch. - - Download the *-dll.zip files if you need Observer modules. - Download the *-pdb.zip files if you need debug symbols. - files: ./artifacts/*.zip + tag_name: ${{ steps.tag.outputs.tag }} + name: Release ${{ steps.tag.outputs.tag }} + body: Automated release from the fully verified master branch. + files: release-assets/*.zip prerelease: false diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..3c0711d --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,120 @@ +name: Mutation testing + +on: + push: + branches: [master] + paths: + - '.github/workflows/mutation.yml' + - 'build/mutation/**' + - 'build/tests/test_mutation_report.py' + - 'src/modules/renpy/pickle.*' + - 'src/tests/unit/pickle.cpp' + - 'src/tests/mutation/**' + - 'vcpkg.json' + pull_request: + branches: [master] + paths: + - '.github/workflows/mutation.yml' + - 'build/mutation/**' + - 'build/tests/test_mutation_report.py' + - 'src/modules/renpy/pickle.*' + - 'src/tests/unit/pickle.cpp' + - 'src/tests/mutation/**' + - 'vcpkg.json' + schedule: + - cron: '41 19 * * 6' + workflow_dispatch: + +concurrency: + group: mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + MULL_VERSION: '0.34.0' + MULL_LLVM: '19' + MULL_REPOSITORY_FINGERPRINT: '6975C1E8A078A727081F4B7541DB35380DE6BD6F' + VCPKG_COMMIT: '9e593bb18ea69cc5095e012465dcd675a822ed0d' + VCPKG_INSTALLED_DIR: ${{ github.workspace }}/.artifacts/vcpkg-installed + +jobs: + pickle-core: + name: Portable pickle core (Mull) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Check out ObserverModules + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Test mutation-report enforcement + shell: bash + run: python3 -m unittest build.tests.test_mutation_report -v + + - name: Check out pinned vcpkg baseline + uses: actions/checkout@v6 + with: + repository: microsoft/vcpkg + ref: ${{ env.VCPKG_COMMIT }} + path: .artifacts/vcpkg-src + persist-credentials: false + + - name: Install pinned Mull toolchain + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends ca-certificates curl gnupg + + key_file="${RUNNER_TEMP}/mull-project.asc" + keyring_file="${RUNNER_TEMP}/mull-project.gpg" + curl --fail --location --proto '=https' --tlsv1.2 \ + 'https://dl.cloudsmith.io/public/mull-project/mull-stable/gpg.41DB35380DE6BD6F.key' \ + --output "${key_file}" + actual_fingerprint="$(gpg --show-keys --with-colons "${key_file}" | awk -F: '$1 == "fpr" { print toupper($10); exit }')" + test "${actual_fingerprint}" = "${MULL_REPOSITORY_FINGERPRINT}" + gpg --batch --yes --dearmor --output "${keyring_file}" "${key_file}" + sudo install -o root -g root -m 0644 "${keyring_file}" /usr/share/keyrings/mull-project.gpg + echo 'deb [signed-by=/usr/share/keyrings/mull-project.gpg] https://dl.cloudsmith.io/public/mull-project/mull-stable/deb/ubuntu noble main' \ + | sudo tee /etc/apt/sources.list.d/mull-project.list >/dev/null + + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + "clang-${MULL_LLVM}" \ + "mull-${MULL_LLVM}=${MULL_VERSION}" + "mull-runner-${MULL_LLVM}" --version + "clang++-${MULL_LLVM}" --version + + - name: Restore pinned Catch2 dependency + shell: bash + run: | + set -euo pipefail + .artifacts/vcpkg-src/bootstrap-vcpkg.sh -disableMetrics + .artifacts/vcpkg-src/vcpkg install catch2:x64-linux \ + --x-install-root="${VCPKG_INSTALLED_DIR}" \ + --clean-after-build + + - name: Run portable mutation gate + id: mutation + continue-on-error: true + shell: bash + run: | + mkdir -p .artifacts/reports/mutation + bash build/mutation/run.sh > .artifacts/reports/mutation/ci.log 2>&1 + + - name: Archive mutation evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: mutation-pickle-core + path: .artifacts/reports/mutation + if-no-files-found: error + retention-days: 30 + + - name: Enforce mutation gate + if: steps.mutation.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/.gitignore b/.gitignore index 0530e07..198203a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/.PVS-Studio -/build +/.artifacts +/.idea/ diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml deleted file mode 100644 index 4ea72a9..0000000 --- a/.idea/copilot.data.migration.agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml deleted file mode 100644 index 7fbe760..0000000 --- a/.idea/dictionaries/project.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - andelf - auriemma - birkenfeld - bstatic - catchorg - debugfarhome - dependencygraph - dest - funcs - ilammy - lazyhamster - luxrck - makemoduleversion - mateidavid - nomoreitems - popd - pushd - refaim's - ren' - renpy - rgssad - rpatool - rpgmaker - shizmob - softprops - strbuf - thirdparty - userabort - vcvars - wstring - xxhash - zanzapak - zanzarah - zstr - - - \ No newline at end of file diff --git a/.idea/editor.xml b/.idea/editor.xml deleted file mode 100644 index 2c855b4..0000000 --- a/.idea/editor.xml +++ /dev/null @@ -1,343 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 0b76fe5..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 348b976..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml b/.idea/runConfigurations/Copy_x64_debug_to_Far.xml deleted file mode 100644 index e0a01cc..0000000 --- a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x64_release_to_Far.xml b/.idea/runConfigurations/Copy_x64_release_to_Far.xml deleted file mode 100644 index 0c2d86e..0000000 --- a/.idea/runConfigurations/Copy_x64_release_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml b/.idea/runConfigurations/Copy_x86_debug_to_Far.xml deleted file mode 100644 index 2096b9b..0000000 --- a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x86_release_to_Far.xml b/.idea/runConfigurations/Copy_x86_release_to_Far.xml deleted file mode 100644 index 808b0e3..0000000 --- a/.idea/runConfigurations/Copy_x86_release_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x64_debug.xml b/.idea/runConfigurations/Far_x64_debug.xml deleted file mode 100644 index bed2e21..0000000 --- a/.idea/runConfigurations/Far_x64_debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x64_release.xml b/.idea/runConfigurations/Far_x64_release.xml deleted file mode 100644 index 3fe2c78..0000000 --- a/.idea/runConfigurations/Far_x64_release.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x86_debug.xml b/.idea/runConfigurations/Far_x86_debug.xml deleted file mode 100644 index f1492bb..0000000 --- a/.idea/runConfigurations/Far_x86_debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x86_release.xml b/.idea/runConfigurations/Far_x86_release.xml deleted file mode 100644 index 3dd962a..0000000 --- a/.idea/runConfigurations/Far_x86_release.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Tests.xml b/.idea/runConfigurations/Run_Tests.xml deleted file mode 100644 index b5dd467..0000000 --- a/.idea/runConfigurations/Run_Tests.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8459a54 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# Repository agent instructions + +## Build + +This project has a console-first Windows build based directly on MSBuild and pinned manifest-mode vcpkg dependencies. +Repository CMake is not part of the build graph; CMake use inside a vcpkg port is acceptable. + +Use the root entry point from an ordinary PowerShell or `cmd.exe` console: + +```powershell +.\build.ps1 doctor +.\build.ps1 build -Arch all -Config Release +.\build.ps1 test -Arch x86,x64 -Config Debug +.\build.ps1 verify -Arch x64 +``` + +Run the relevant build, tests, and checks after changing code. Do not install or update developer tools automatically; +report missing prerequisites to the user. Release modules must remain MSVC-built, `/MT`, self-contained binaries for +x86, x64, and ARM64 with no third-party runtime DLLs. + +All production changes follow strict TDD: state the observable requirement or invariant, add a focused test that fails +for the expected reason, implement the smallest correct change, and refactor only while the suite remains green. Every +bug fix starts with a regression test. The required coverage gate is 100% first-party source lines and branches; do not +weaken thresholds, exclude production files, write coverage-only tests with no behavioral assertion, or add broad +suppressions to make a check pass. + +## Development guidelines + +This project uses C++23 and follows the high-assurance engineering policy in +`docs/critical-software-methodology.md`. In particular: + +- Preserve clean dependency direction: platform/Observer adapters depend on application and parser code, never the + reverse. Format parsing belongs in a platform-neutral core and must not acquire Observer or Win32 dependencies. +- Keep the C ABI boundary strict. Export only C-compatible, fixed-layout data and explicit sizes. Never let STL types, + C++ exceptions, allocator ownership, or implicit lifetime assumptions cross the boundary. Validate all inbound + pointers and structure sizes, initialize outputs defensively, and translate every internal failure to the documented + ABI result at the outermost boundary. +- Use value semantics and RAII for every resource, including memory, files, module handles, and temporary output. + Application C++ must not contain owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free`. A raw pointer or + reference is non-owning; prefer references, `std::span`, and `std::string_view` where they express the contract. + Prefer `std::unique_ptr` for polymorphic ownership. `std::shared_ptr` requires a documented, genuinely shared + lifetime; use `std::weak_ptr` to break cycles. +- Treat archive bytes, metadata, paths, counts, offsets, sizes, and callback behavior as untrusted input. Validate + before use, use checked arithmetic before narrowing/allocation/seeking, impose explicit resource and iteration + bounds, guarantee loop progress, and avoid input-driven recursion unless a strict depth limit is enforced. +- Express security-relevant condition combinations as executable, data-driven decision-table tests. Mutation testing + of first-party parser/application logic is a mandatory test-quality gate; a surviving non-equivalent mutant is a + test defect, not an acceptable score reduction. +- Keep functions cohesive, control flow reviewable, ownership explicit, and preprocessor use minimal. Avoid magic + numbers, hidden global state, duplicated policy, and speculative abstraction. KISS and DRY remain subordinate to + clear boundaries and independently testable behavior. +- A check suppression or deviation must be narrow, explained beside the code or in the decision log, and reviewed by + the owner. Never catch `std::bad_alloc`, `std::length_error`, access violations, or sanitizer findings merely to make + fuzzing or tests pass. + +## Architecture overview + +This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. Each module +implements the Observer API for one format family. + +### Core components + +- **API layer** (`src/api.h`, `src/dll.cpp`): Observer entry points such as `OpenStorage`, `CloseStorage`, `GetItem`, + and `ExtractItem`. +- **Archive wrapper** (`src/archive.h`, `src/archive.cpp`): common archive lifecycle and extraction behavior. +- **Extractor interface** (`src/modules/extractor.h`): the internal contract implemented by each format module. + +### Module structure + +Supported modules live under `src/modules/`: + +- `renpy/`: Ren'Py RPA archives and their Pickle index parser; +- `rpgmaker/`: RPG Maker VX Ace RGSS3A archives; +- `zanzarah/`: Zanzarah PAK archives. + +Each contains format-specific implementation, a `.def` export definition, and `observer_user.ini` registration data. + +### Data flow + +1. FAR Manager/Observer loads the module through `LoadSubModule()`. +2. `OpenStorage()` creates an archive wrapper with the format extractor. +3. `PrepareFiles()` validates and indexes archive contents. +4. `GetItem()` exposes entry metadata. +5. `ExtractItem()` streams an entry to the requested destination with progress/cancellation reporting. + +### Tests + +Catch2 tests live in `src/tests/`. Unit tests exercise parser logic directly, while integration and ABI contract tests +load the actual module binaries without requiring FAR Manager. Small repository-owned fixtures are mandatory. The +external golden corpus is an optional compatibility/stress layer selected with `-Corpus`. + +See `docs/build-system.md` for the current command contract and `docs/autonomous-work-log.md` for unresolved decisions +from ongoing build-system work. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b4e1c08..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build - -This project uses CMake with vcpkg for dependency management, builds only on Windows and requires Visual Studio 2017+. - -Do not attempt to build the code or run tests, as the environment is not set up for it. - -## Development Guidelines - -This project uses **C++23** and follows KISS (Keep It Simple, Stupid) and DRY (Don't Repeat Yourself) principles. - -Avoid magic numbers. - -## Architecture Overview - -This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. The codebase follows -a plugin architecture where each module implements the Observer API to support different archive formats. - -### Core Components - -- **API Layer** (`src/api.h`, `src/dll.cpp`): Implements the Observer plugin API with standard functions like - `OpenStorage`, `CloseStorage`, `GetItem`, `ExtractItem` -- **Archive Wrapper** (`src/archive.h`, `src/archive.cpp`): Provides a unified interface that wraps format-specific - extractors -- **Extractor Interface** (`src/modules/extractor.h`): Defines the abstract interface that all format extractors must - implement - -### Module Structure - -Each supported format has its own module under `src/modules/`: - -- `renpy/`: RenPy visual novel archives (.rpa files) with pickle support -- `zanzarah/`: Zanzarah game archives (.pak files) -- `rpgmaker/`: RPG Maker archives (in development) - -Each module contains: - -- Format-specific implementation (e.g., `renpy.cpp`) -- Module definition file (`.def`) for DLL exports -- Configuration file (`observer_user.ini`) - -### Data Flow - -1. FAR Manager loads the module DLL via `LoadSubModule()` -2. `OpenStorage()` creates an archive wrapper with format-specific extractor -3. `PrepareFiles()` scans and indexes archive contents -4. `GetItem()` provides file metadata for FAR's file browser -5. `ExtractItem()` handles actual file extraction with progress callbacks - -### Testing Framework - -Located in `src/tests/` with a custom framework (`framework/observer.h`) that simulates the Observer API for testing -archive operations without requiring FAR Manager. \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 6508d49..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,96 +0,0 @@ -cmake_minimum_required(VERSION 3.31) - -if (DEFINED ENV{VCPKG_ROOT}) - set(VCPKG_ROOT "$ENV{VCPKG_ROOT}") -elseif (DEFINED ENV{USERPROFILE}) - set(VCPKG_ROOT "$ENV{USERPROFILE}/.vcpkg-clion/vcpkg") -endif () -if (NOT EXISTS ${VCPKG_ROOT}) - message(FATAL_ERROR "VCPKG_ROOT is not defined. Please set it to the path of your vcpkg installation.") -endif () -file(TO_CMAKE_PATH ${VCPKG_ROOT} VCPKG_ROOT) -set(CMAKE_TOOLCHAIN_FILE "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake") - -set(VCPKG_CRT_LINKAGE static) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_TARGET_TRIPLET ${OBSERVER_ARCHITECTURE}-windows-static) - -project(observer_modules LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_compile_options("/W3" "/analyze") -set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") - -set(ZLIB_USE_STATIC_LIBS ON) -find_package(ZLIB REQUIRED) -find_path(ZSTR_INCLUDE_DIRS "zstr.hpp") - -set(ALL_MODULES renpy rpgmaker zanzarah) -set(RELEASED_MODULES renpy rpgmaker zanzarah) - -set(RENPY_DIR src/modules/renpy) -add_library(renpy SHARED - src/dll.cpp - src/archive.cpp - ${RENPY_DIR}/renpy.cpp - ${RENPY_DIR}/pickle.cpp -) -target_link_libraries(renpy PRIVATE ZLIB::ZLIB) -target_include_directories(renpy PRIVATE ${ZSTR_INCLUDE_DIRS}) - -add_library(rpgmaker SHARED src/dll.cpp src/archive.cpp src/modules/rpgmaker/rpgmaker.cpp) - -add_library(zanzarah SHARED src/dll.cpp src/archive.cpp src/modules/zanzarah/zanzarah.cpp) - -foreach (module IN LISTS ALL_MODULES) - set_target_properties(${module} PROPERTIES SUFFIX ".so" PREFIX "" LINK_FLAGS "/DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/modules/${module}/${module}.def") -endforeach () - -# === CTest === - -find_package(Catch2 REQUIRED) -find_package(nlohmann_json REQUIRED) -find_package(xxHash CONFIG REQUIRED) -add_executable(tests - src/tests/framework/observer.cpp - src/tests/framework/testcase.cpp - src/tests/renpy.cpp - src/tests/rpgmaker.cpp - src/tests/zanzarah.cpp -) -target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) -target_link_libraries(tests PRIVATE nlohmann_json::nlohmann_json) -target_link_libraries(tests PRIVATE xxHash::xxhash) -target_link_libraries(tests PRIVATE ${ALL_MODULES}) -include(CTest) -include(Catch) -catch_discover_tests(tests) - -# === CPack === - -file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/thirdparty) -file(COPY ${CMAKE_SOURCE_DIR}/licenses/ DESTINATION ${CMAKE_BINARY_DIR}/docs_temp/thirdparty) - -string(TIMESTAMP TODAY "%Y-%m-%d") -foreach (MODULE IN LISTS RELEASED_MODULES) - install(TARGETS ${MODULE} RUNTIME DESTINATION . COMPONENT ${MODULE}) - install(FILES ${CMAKE_SOURCE_DIR}/src/modules/${MODULE}/observer_user.ini DESTINATION . COMPONENT ${MODULE}) - - install(FILES ${CMAKE_SOURCE_DIR}/LICENSE.txt DESTINATION docs RENAME license.txt COMPONENT ${MODULE}) - install(DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/ DESTINATION docs COMPONENT ${MODULE}) - - install(FILES "$" DESTINATION . COMPONENT ${MODULE}_pdb) - - string(TOUPPER ${MODULE} MODULE_UPPER) - set(CPACK_ARCHIVE_${MODULE_UPPER}_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-dll") - set(CPACK_ARCHIVE_${MODULE_UPPER}_PDB_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-pdb") -endforeach () - -list(TRANSFORM RELEASED_MODULES APPEND "_pdb" OUTPUT_VARIABLE ALL_COMPONENTS) -list(PREPEND ALL_COMPONENTS ${RELEASED_MODULES}) - -set(CPACK_GENERATOR ZIP) -set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) -set(CPACK_COMPONENTS_ALL ${ALL_COMPONENTS}) - -include(CPack) \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json deleted file mode 100644 index cdb22cd..0000000 --- a/CMakePresets.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "version": 3, - "configurePresets": [ - { - "hidden": true, - "name": "default", - "generator": "Ninja", - "vendor": { - "jetbrains.com/clion": { - "toolchain": "Visual Studio" - } - } - }, - { - "name": "x64-debug", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x64-debug", - "architecture": { - "value": "x64", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "OBSERVER_ARCHITECTURE": "x64" - } - }, - { - "name": "x64-release", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x64-release", - "architecture": { - "value": "x64", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "OBSERVER_ARCHITECTURE": "x64" - } - }, - { - "name": "x86-debug", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x86-debug", - "architecture": { - "value": "Win32", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "OBSERVER_ARCHITECTURE": "x86" - } - }, - { - "name": "x86-release", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x86-release", - "architecture": { - "value": "Win32", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "OBSERVER_ARCHITECTURE": "x86" - } - } - ] -} diff --git a/README.md b/README.md index 5063a2e..58b86a7 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,6 @@ specific files as needed without having to unpack the entire archive. | [lazyhamster/Observer](https://github.com/lazyhamster/Observer) | [LGPL-3.0](licenses/Observer.txt) | | [Cyan4973/xxHash](https://github.com/Cyan4973/xxHash) | [BSD-2-Clause](licenses/xxHash.txt) | | [zlib](https://zlib.net) | [zlib](licenses/zlib.txt) | -| [mateidavid/zstr](https://github.com/mateidavid/zstr) | [MIT](licenses/zstr.txt) | ## Sources of inspiration @@ -84,9 +83,21 @@ specific files as needed without having to unpack the entire archive. ### Prerequisites -- **Visual Studio 2017** compiler -- **CLion** and/or **CMake** (version 3.31+) -- **vcpkg** +- Visual Studio Build Tools 2022 with the v143 MSVC tools for x86/x64 and ARM64, Spectre-mitigated libraries, and a + Windows 11 SDK +- PowerShell 7.4 or newer +- vcpkg available on `PATH` or through `VCPKG_ROOT` -You can open the included CLion project directly and build through the IDE or use CMake manually to generate the build -files, then compile using the Visual Studio compiler. +No IDE, Visual Studio developer prompt, global vcpkg integration, or repository-level CMake generation is required. +From a normal Windows console: + +```powershell +.\build.ps1 doctor +.\build.ps1 build -Arch all -Config Release +.\build.ps1 test -Arch x86,x64 -Config Debug +.\build.ps1 package -Arch all +``` + +The build restores pinned static dependencies and produces self-contained `/MT` modules for x86, x64, and ARM64. +See [the build-system documentation](docs/build-system.md) for analysis, coverage, sanitizer, fuzzing, binary-audit, and +packaging commands. diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..493a877 --- /dev/null +++ b/build.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoLogo -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..acced7b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,6 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +& (Join-Path $PSScriptRoot 'build\build.ps1') @args diff --git a/build/GRAPH.md b/build/GRAPH.md new file mode 100644 index 0000000..1527703 --- /dev/null +++ b/build/GRAPH.md @@ -0,0 +1,167 @@ +# Experimental build graph driver + +`graph_driver.py` is a stdlib-only outer DAG experiment. It schedules existing +`build.ps1` commands; it does not replace their MSBuild, vcpkg, analysis, test, or +packaging implementation. Nothing calls the driver from the default build entrypoint. + +MSBuild is the retained native compile/link backend. A direct Python-to-compiler driver +is out of scope; a Ninja-backed prototype would require new build-only evidence showing +a material native critical-path problem before it is considered. + +## Reproducible invocation + +The experiment was verified with uv-managed CPython 3.14.6. This invocation pins the +exact interpreter, disables Python downloads, disables network access, ignores uv +project configuration, and does not install project dependencies: + +```powershell +uv run --no-python-downloads --offline --no-config ` + --cache-dir .artifacts\uv-graph-cache --python 3.14.6 ` + python build\graph_driver.py plan --graph observer-verify-shadow +``` + +If CPython 3.14.6 is not already available to uv, the command fails instead of +installing or updating it. The driver itself has no third-party Python dependencies. +The synthetic profile's `{python}` token expands to the absolute current +`sys.executable`, so child commands neither search `PATH` nor recursively invoke uv. + +Add `--json` for a stable machine-readable plan. Variables are explicit: + +```powershell +uv run --no-python-downloads --offline --no-config ` + --cache-dir .artifacts\uv-graph-cache --python 3.14.6 ` + python build\graph_driver.py plan --graph observer-verify-shadow ` + --set arch=x64 --set fuzz_seconds=60 --json +``` + +`run` is deliberately separate from `plan`; running `observer-verify-shadow` invokes +the real, expensive leaf gates. Per-node output is written below +`.artifacts/graph//logs`, and cache state below the adjacent `state` directory. +Use `--jobs 3` for this profile; higher widths have not been justified on the current +host because each native leaf may already parallelize internally through MSBuild. + +## Execution contract + +- The plan is a deterministic, name-stable topological order. Cycles and unknown + dependencies are rejected before any command starts. +- Commands are JSON argv arrays and execute with `shell=False`, a fixed workspace, + closed stdin, and combined stdout/stderr in a node-specific log. +- A failed node blocks every transitive dependent. Independent ready nodes may finish. +- Each node names one resource pool. Pool capacities and `--jobs` are both enforced + within a driver process. +- A fingerprint includes argv, pool, cache policy, output declarations, explicit + fingerprint tokens, SHA-256 records for matched inputs, and dependency fingerprints. +- A cacheable node is skipped only when its successful state fingerprint matches and + every explicit output still exists. Cacheable nodes without outputs are invalid. +- `cacheable: false` gates always run, even when their fingerprints are unchanged. + +The Observer shadow profile keeps every native gate non-cacheable. It first completes +`doctor`, then one serial `restore -RestoreFlavor all`, and then `source-checks`. The +restore prepares both normal and ASan x64 install roots before any parallel leaf starts; +every later leaf passes `-SkipDependencyRestore`, so concurrent vcpkg processes cannot +race on the manifest lock. The public CLI still restores by default, and a direct +`restore -SkipDependencyRestore` invocation is rejected as a false-success hazard. + +After source checks, the graph fans out into independently safe branches: + +- Debug tests precede compiler analysis because both reuse the Debug object tree. +- Release tests, coverage, and UBSan have configuration-separated object trees and may + overlap the Debug/analysis branch. +- The coarse pilot serializes ASan before fuzzing. The completed output-scope audit found + that this edge is not required: ASan and Fuzz use configuration-separated object and + binary trees, and the restored dependency roots are read-only during both gates. + +The five branch tails join before UMDH, so leak instrumentation never overlaps another +heavy gate, and packaging runs last so its destructive staging cleanup cannot race +another gate. The `normal-x64` pool capacity is three, the ASan pool remains one, and +the recommended global `--jobs 3` caps total external concurrency at three. + +The current full profile defaults to x64 because UBSan and UMDH leak execution are +x64-specific; an all-architecture profile should split build-only ARM64 work from +host-runnable gates instead of merely overriding `arch=all`. + +The profile covers `doctor`, source checks, Debug and Release tests, compiler analysis, +coverage, ASan, UBSan, UMDH leaks, all-format fuzzing, and packaging. Packaging performs +the Release binary audit itself, so a separate `audit-binaries` node would duplicate work. + +## Synthetic cache benchmark + +The checked-in `synthetic-cache-benchmark` profile has one prepare node, two parallel +cacheable branches, and a non-cacheable terminal gate. On the development host on +2026-08-01: + +| Run | Result | Driver time | +| --- | --- | ---: | +| Sequential cold (`--jobs 1`) | all four nodes executed | 0.512 s | +| DAG cold (`--jobs 3`) | compile branches overlapped | 0.383 s | +| DAG warm (`--jobs 3`) | three cache hits; gate still executed | 0.070 s | + +The scheduler supplied a 1.34x cold improvement, and the warm run was 7.31x faster than +the sequential cold baseline. These synthetic results demonstrate concurrency and +cache behavior only; they are not native build performance claims. + +## Real x64 verify benchmark + +The full local x64 gate was measured on the same warm development worktree. Both +successful runs executed every host-capable gate with no deferrals: + +| Run | Result | Wall time | +| --- | --- | ---: | +| Sequential `build.ps1 verify` | green | 1121.262 s | +| Coarse DAG (`--jobs 3`) | green | 834.592 s | + +The coarse graph saved 286.670 seconds (25.6%, 1.34x). Its single +`compiler-analysis` leaf still took 673.687 seconds, while all-format fuzzing took +253.546 seconds and the leak gate took 92.583 seconds. Those timings establish the next +step: replace monolithic gate leaves with project, translation-unit, fuzz-target, +leak-scenario, audit-tool, and package-unit fan-out/fan-in. The measured coarse profile +is a baseline, not the target architecture. + +## Size and replacement projection + +The implementation was reduced from 756 to 533 production lines after review. Its +remaining groups are approximately: 116 lines of models/path validation, 195 lines of +graph validation/topological planning/content fingerprints, 129 lines of safe process +execution/scheduling/cache/log handling, and 93 lines of JSON expansion plus CLI. +Cycle diagnostics, failure propagation, path confinement, and `shell=False` execution +were retained rather than compressed away. + +This pilot does **not** currently reduce repository LOC: it adds 533 driver lines plus +the profile while replacing none of the Windows leaf implementation or the default +entrypoint. The decomposed implementation currently has a 1,531-line internal entrypoint +and 2,321 PowerShell production lines including its root forwarder and `build/lib` files. +Replacing only `verify.ps1` and `verify-routing.ps1` with this outer scheduler would add +roughly 550 net production/config lines, so adoption on LOC grounds would fail. + +A full replacement would still need Visual Studio discovery, MSBuild/vcpkg execution, +coverage/sanitizer/UMDH/audit/package implementations, and their tests. The pilot has not +ported enough of that surface to support a credible full-replacement LOC estimate. Native +timing and a bounded leaf-port spike must justify any further migration. + +## Do the recipes need templating? + +Not yet. The observed repetition is limited to the `pwsh ... build.ps1` argv prefix, +shared input glob sets, and similar gate records. If this profile grows, schema-native +command prefixes, named input sets, and node defaults would remove those repetitions +while preserving reviewable normalized JSON. + +Dependencies, pool selection, cacheability, outputs, and security-sensitive gate flags +should remain explicit. Jinja would make the executable graph harder to review and add +another runtime. Only a much larger architecture/configuration matrix would justify +generation; even then, prefer a typed stdlib Python generator that emits and validates +normalized JSON over a text-template language. + +## Experimental limitations + +Pool limits and cache state are process-local; concurrent driver processes are not +locked against each other. The cache is local and only as complete as each node's +declared inputs and outputs. Environment variables are inherited. Production adoption +would additionally require cancellation policy, interprocess locking, complete graph +profiles for x86/x64/ARM64 routing, and the same source-quality coverage expected of +the existing build scripts. + +Accordingly the coarse pilot proves useful scheduling but is not yet production-ready. +Its full x64 run is green and materially faster, while the measurements show that +monolithic analysis and dynamic leaves still hide most available parallelism. +`build.ps1` remains the correct default until the fine-grained graph has equivalent +local evidence for the supported architecture matrix. diff --git a/build/ObserverConfiguration.props b/build/ObserverConfiguration.props new file mode 100644 index 0000000..bd80ca8 --- /dev/null +++ b/build/ObserverConfiguration.props @@ -0,0 +1,15 @@ + + + + + true + $(ObserverConfigurationType) + true + false + v143 + ClangCL + Unicode + true + Spectre + + diff --git a/build/ObserverFuzz.props b/build/ObserverFuzz.props new file mode 100644 index 0000000..c0c455c --- /dev/null +++ b/build/ObserverFuzz.props @@ -0,0 +1,14 @@ + + + + + Console + $(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib;%(AdditionalDependencies) + /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.fuzzer-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib" /INFERASANLIBS:NO %(AdditionalOptions) + + + + + + diff --git a/build/ObserverModules.proj b/build/ObserverModules.proj new file mode 100644 index 0000000..cb2c6bc --- /dev/null +++ b/build/ObserverModules.proj @@ -0,0 +1,131 @@ + + + + Debug + x64 + x86 + x64 + arm64 + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\.artifacts\reports\msvc\')) + $([System.IO.Path]::GetFullPath('$(ObserverAnalysisReportDirectory)\')) + $([System.IO.Path]::GetFullPath('$(AnalysisRequestedReportRoot)$(AnalysisPlatformMoniker)\')) + Configuration=$(Configuration);Platform=$(Platform);VcpkgRoot=$(VcpkgRoot);ObserverRunCodeAnalysis=$(ObserverRunCodeAnalysis);ObserverAnalysisReportDirectory=$(ObserverAnalysisReportDirectory);ObserverEnableClangTidy=$(ObserverEnableClangTidy);LLVMInstallDir=$(LLVMInstallDir);LLVMRuntimeDir=$(LLVMRuntimeDir) + + + + + + + + + + + + + renpy + Debug + Win32;x64;ARM64 + + + rpgmaker + Debug + Win32;x64;ARM64 + + + zanzarah + Debug + Win32;x64;ARM64 + + + tests + Debug + Win32;x64;ARM64 + + + fuzz-pickle + Debug + Win32;x64;ARM64 + + + fuzz-renpy + Debug + Win32;x64;ARM64 + + + fuzz-rpgmaker + Debug + Win32;x64;ARM64 + + + fuzz-zanzarah + Debug + Win32;x64;ARM64 + + + leak-probe + Release + x64 + + + + + + + + + + + + + + + + + + + + + + + + + $([System.IO.File]::GetAttributes('%(AnalysisReportPathToValidate.FullPath)')) + + + + + + + + + + + + + + + + + + + + + diff --git a/build/ObserverNativeAnalysis.ruleset b/build/ObserverNativeAnalysis.ruleset new file mode 100644 index 0000000..d5b8986 --- /dev/null +++ b/build/ObserverNativeAnalysis.ruleset @@ -0,0 +1,6 @@ + + + + diff --git a/build/ObserverProject.props b/build/ObserverProject.props new file mode 100644 index 0000000..620f5c1 --- /dev/null +++ b/build/ObserverProject.props @@ -0,0 +1,85 @@ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\')) + $(RepositoryRoot).artifacts\ + x86 + x64 + arm64 + observer-$(PlatformMoniker)-windows-static + observer-$(PlatformMoniker)-windows-static-asan + true + + false + + false + $(RepositoryRoot) + + $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)\ + $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)-asan\ + false + --overlay-triplets=$(RepositoryRoot)build\vcpkg\triplets + $(ArtifactsRoot)bin\$(PlatformMoniker)\$(Configuration)\ + $(ArtifactsRoot)obj\$(PlatformMoniker)\$(Configuration)\$(ProjectName)\ + $(OutDir) + $(ProjectName) + true + false + true + true + $(RepositoryRoot)build\ObserverNativeAnalysis.ruleset + $(ProjectName) + true + + + + + stdcpp23 + stdcpplatest + Level4 + true + true + true + TurnOffAllWarnings + true + true + false + true + Guard + true + $(ObserverAnalysisReportDirectory)\$(PlatformMoniker)\$(ObserverAnalysisReportName).sarif + $(ObserverAnalysisReportPath) + Sync + MultiThreaded + MultiThreadedDebug + ProgramDatabase + EnableFastChecks + Default + false + Disabled + MaxSpeed + true + true + $(RepositoryRoot)src;%(AdditionalIncludeDirectories) + NOMINMAX;%(PreprocessorDefinitions) + /utf-8 /Zc:__cplusplus %(AdditionalOptions) + /clang:-fprofile-instr-generate /clang:-fcoverage-mapping %(AdditionalOptions) + /fsanitize=address %(AdditionalOptions) + /clang:-fsanitize=undefined /clang:-fno-sanitize-recover=all %(AdditionalOptions) + /clang:-fsanitize=fuzzer,address %(AdditionalOptions) + + + true + true + true + true + true + Guard + true + true + true + UseLinkTimeCodeGeneration + $(LLVMRuntimeDir)\clang_rt.ubsan_standalone-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.ubsan_standalone_cxx-x86_64.lib;%(AdditionalDependencies) + + + diff --git a/build/ObserverProjectConfigurations.props b/build/ObserverProjectConfigurations.props new file mode 100644 index 0000000..73b8947 --- /dev/null +++ b/build/ObserverProjectConfigurations.props @@ -0,0 +1,19 @@ + + + + DebugWin32 + Debugx64 + DebugARM64 + ReleaseWin32 + Releasex64 + ReleaseARM64 + CoverageWin32 + Coveragex64 + CoverageARM64 + ASanWin32 + ASanx64 + UBSanx64 + FuzzWin32 + Fuzzx64 + + diff --git a/build/PSScriptAnalyzerSettings.psd1 b/build/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..a6b326a --- /dev/null +++ b/build/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,6 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + 'PSAvoidUsingWriteHost' + ) +} diff --git a/build/build.ps1 b/build/build.ps1 new file mode 100644 index 0000000..ec23e38 --- /dev/null +++ b/build/build.ps1 @@ -0,0 +1,1575 @@ +#requires -Version 7.4 + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet( + 'help', + 'doctor', + 'restore', + 'build', + 'test', + 'source-checks', + 'compiler-analysis', + 'test-coverage', + 'test-asan', + 'test-ubsan', + 'test-leaks', + 'fuzz', + 'audit-binaries', + 'package', + 'verify', + 'clean' + )] + [string] $Command = 'help', + + [string[]] $Arch = @('x64'), + + [ValidateSet('Debug', 'Release')] + [string] $Config = 'Debug', + + [string] $Corpus, + + [ValidateSet('default', 'asan', 'all')] + [string] $RestoreFlavor = 'default', + + [switch] $SkipDependencyRestore, + + [ValidateRange(1, 86400)] + [int] $FuzzSeconds = 60, + + [ValidateSet('all', 'pickle', 'renpy', 'rpgmaker', 'zanzarah')] + [string] $FuzzTarget = 'all', + + [ValidateRange(1, 1000000)] + [int] $LeakWarmup = 8, + + [ValidateRange(1, 1000000)] + [int] $LeakIterations = 100, + + [ValidateRange(3, 10)] + [int] $LeakWindows = 3, + + [ValidateRange(0, 1073741824)] + [int64] $LeakToleranceBytes = 0, + + [ValidateRange(0, 100)] + [double] $CoverageThreshold = 100 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:BuildRoot = $PSScriptRoot +$script:RepositoryRoot = Split-Path $script:BuildRoot -Parent +$script:AggregateProject = Join-Path $script:BuildRoot 'ObserverModules.proj' +$script:ArtifactsRoot = Join-Path $script:RepositoryRoot '.artifacts' +$script:KnownArchitectures = @('x86', 'x64', 'arm64') +$script:ModuleNames = @('renpy', 'rpgmaker', 'zanzarah') + +. (Join-Path $script:BuildRoot 'lib\package-manifest.ps1') +. (Join-Path $script:BuildRoot 'lib\analysis-reporting.ps1') +. (Join-Path $script:BuildRoot 'lib\common.ps1') +. (Join-Path $script:BuildRoot 'lib\package-smoke.ps1') +. (Join-Path $script:BuildRoot 'lib\verify-routing.ps1') +. (Join-Path $script:BuildRoot 'lib\packaging.ps1') +. (Join-Path $script:BuildRoot 'lib\verify.ps1') + +function Resolve-VisualStudio { + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + $vswhere = Join-Path $programFilesX86 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'vswhere.exe was not found. Install Visual Studio 2022 Build Tools with the Desktop development with C++ workload.' + } + + $installationPath = @( + & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -property installationPath + ) | Select-Object -First 1 + if (-not $installationPath) { + throw 'Visual Studio Build Tools with MSBuild were not found.' + } + + $msbuild = Join-Path $installationPath 'MSBuild\Current\Bin\amd64\MSBuild.exe' + if (-not (Test-Path -LiteralPath $msbuild)) { + $msbuild = Join-Path $installationPath 'MSBuild\Current\Bin\MSBuild.exe' + } + if (-not (Test-Path -LiteralPath $msbuild)) { + throw "MSBuild.exe was not found under '$installationPath'." + } + + $toolsetDirectory = Get-ChildItem -Directory -LiteralPath (Join-Path $installationPath 'VC\Tools\MSVC') | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 + if (-not $toolsetDirectory) { + throw "No MSVC toolset was found under '$installationPath'." + } + + $dumpbin = Join-Path $toolsetDirectory.FullName 'bin\Hostx64\x64\dumpbin.exe' + if (-not (Test-Path -LiteralPath $dumpbin)) { + throw "dumpbin.exe was not found under '$($toolsetDirectory.FullName)'." + } + + $developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path -LiteralPath $developerCommand)) { + throw "VsDevCmd.bat was not found under '$installationPath'." + } + + return [pscustomobject]@{ + InstallationPath = $installationPath.ToString() + MSBuild = $msbuild + ToolsetDirectory = $toolsetDirectory.FullName + ToolsetVersion = $toolsetDirectory.Name + Dumpbin = $dumpbin + DeveloperCommand = $developerCommand + Vswhere = $vswhere + } +} + +function Initialize-MSVCEnvironment { + param( + [Parameter(Mandatory)] $VisualStudio, + [Parameter(Mandatory)][string] $Architecture + ) + + $developerArchitecture = switch ($Architecture) { + 'x86' { 'x86' } + 'x64' { 'amd64' } + 'arm64' { 'arm64' } + default { throw "Unsupported architecture '$Architecture'." } + } + $commandProcessor = [Environment]::GetEnvironmentVariable('ComSpec', 'Process') + if (-not $commandProcessor) { + $commandProcessor = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'cmd.exe' + } + $commandLine = 'call "{0}" -no_logo -arch={1} -host_arch=amd64 >nul && set' -f $VisualStudio.DeveloperCommand, $developerArchitecture + $environmentLines = @(& $commandProcessor /d /s /c $commandLine) + if ($LASTEXITCODE -ne 0) { + throw "VsDevCmd failed for architecture '$Architecture'." + } + foreach ($line in $environmentLines) { + if ($line -match '^([^=]+)=(.*)$') { + [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') + } + } + + # Some process launchers inject both PATH and Path. .NET Framework build tasks reject that environment block. + $cleanPath = [Environment]::GetEnvironmentVariable('PATH', 'Process') + [Environment]::SetEnvironmentVariable('PATH', $null, 'Process') + [Environment]::SetEnvironmentVariable('Path', $null, 'Process') + [Environment]::SetEnvironmentVariable('PATH', $cleanPath, 'Process') + + # VsDevCmd includes optional ATL/MFC directories even when that workload is absent. + # Roslyn-backed MSBuild tasks warn about every nonexistent LIB entry, so retain only real paths. + $cleanLibraryPath = @( + [Environment]::GetEnvironmentVariable('LIB', 'Process') -split ';' | + Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) } + ) -join ';' + [Environment]::SetEnvironmentVariable('LIB', $null, 'Process') + [Environment]::SetEnvironmentVariable('Lib', $null, 'Process') + [Environment]::SetEnvironmentVariable('LIB', $cleanLibraryPath, 'Process') +} + +function Resolve-Vcpkg { + $candidates = [System.Collections.Generic.List[string]]::new() + + $environmentRoot = [Environment]::GetEnvironmentVariable('VCPKG_ROOT', 'Process') + if ($environmentRoot) { + $candidates.Add($environmentRoot) + } + + $vcpkgCommand = Get-Command vcpkg.exe -ErrorAction SilentlyContinue + if ($vcpkgCommand) { + $commandDirectory = Split-Path $vcpkgCommand.Source -Parent + $candidates.Add($commandDirectory) + $candidates.Add((Join-Path (Split-Path $commandDirectory -Parent) 'apps\vcpkg\current')) + } + + $scoopCommand = Get-Command scoop -ErrorAction SilentlyContinue + if ($scoopCommand) { + try { + $scoopPrefix = @(& $scoopCommand.Source prefix vcpkg 2>$null) | Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $scoopPrefix) { + $candidates.Add($scoopPrefix.ToString()) + } + } catch { + Write-Verbose "Scoop prefix lookup failed; direct vcpkg candidates remain available: $($_.Exception.Message)" + } + } + + foreach ($candidate in $candidates) { + if (-not $candidate) { + continue + } + $root = [System.IO.Path]::GetFullPath($candidate) + $executable = Join-Path $root 'vcpkg.exe' + $props = Join-Path $root 'scripts\buildsystems\msbuild\vcpkg.props' + if ((Test-Path -LiteralPath $executable) -and (Test-Path -LiteralPath $props)) { + return [pscustomobject]@{ Root = $root; Executable = $executable } + } + } + + throw 'A complete vcpkg installation was not found. Set VCPKG_ROOT or install vcpkg with Scoop.' +} + +function Resolve-Llvm { + $candidateDirectories = [System.Collections.Generic.List[string]]::new() + $tidyCommand = Get-Command clang-tidy.exe -ErrorAction SilentlyContinue + if ($tidyCommand) { + $candidateDirectories.Add((Split-Path $tidyCommand.Source -Parent)) + } + + try { + $visualStudio = Resolve-VisualStudio + $candidateDirectories.Add((Join-Path $visualStudio.InstallationPath 'VC\Tools\Llvm\x64\bin')) + $candidateDirectories.Add((Join-Path $visualStudio.InstallationPath 'VC\Tools\Llvm\bin')) + } + catch { + Write-Verbose "Visual Studio LLVM lookup failed; a standalone LLVM installation may still be available: $($_.Exception.Message)" + } + + foreach ($binDirectory in $candidateDirectories | Select-Object -Unique) { + $tools = @{ + Clang = Join-Path $binDirectory 'clang-cl.exe' + Cov = Join-Path $binDirectory 'llvm-cov.exe' + Format = Join-Path $binDirectory 'clang-format.exe' + Profdata = Join-Path $binDirectory 'llvm-profdata.exe' + Tidy = Join-Path $binDirectory 'clang-tidy.exe' + } + if (@($tools.Values | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count -eq 0) { + return [pscustomobject]@{ + Root = Split-Path $binDirectory -Parent + Bin = $binDirectory + Clang = $tools.Clang + Cov = $tools.Cov + Format = $tools.Format + Profdata = $tools.Profdata + Tidy = $tools.Tidy + } + } + } + + throw 'A complete LLVM toolset (clang-cl, clang-format, clang-tidy, llvm-cov, llvm-profdata) was not found.' +} + +function Resolve-Cppcheck { + $command = Get-Command cppcheck.exe -ErrorAction SilentlyContinue + if (-not $command) { + throw 'cppcheck.exe was not found. Install Cppcheck and ensure it is available on PATH.' + } + return $command.Source +} + +function Resolve-BinSkim { + $command = Get-Command BinSkim.exe -ErrorAction SilentlyContinue + if (-not $command) { + throw 'BinSkim.exe was not found. Install Microsoft.CodeAnalysis.BinSkim and ensure it is available on PATH.' + } + return $command.Source +} + +function Resolve-WindowsDebuggingTool { + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + $candidates = @( + Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64' + Join-Path $programFilesX86 'Windows Kits\11\Debuggers\x64' + ) + + foreach ($directory in $candidates) { + $umdh = Join-Path $directory 'umdh.exe' + if (Test-Path -LiteralPath $umdh -PathType Leaf) { + return [pscustomobject]@{ + Directory = $directory + Umdh = $umdh + } + } + } + + throw 'UMDH and gflags.exe were not found. Enable Debugging Tools for Windows in the installed Windows SDK.' +} + +function Invoke-MSBuild { + param( + [Parameter(Mandatory)][string] $Target, + [Parameter(Mandatory)][string] $Architecture, + [Parameter(Mandatory)][string] $Configuration, + [Parameter()][hashtable] $Properties = @{} + ) + + $visualStudio = Resolve-VisualStudio + Initialize-MSVCEnvironment -VisualStudio $visualStudio -Architecture $Architecture + $vcpkg = Resolve-Vcpkg + $platform = Get-MSBuildPlatform $Architecture + $arguments = [System.Collections.Generic.List[string]]::new() + $arguments.Add($script:AggregateProject) + $arguments.Add('/nologo') + $arguments.Add('/m') + $arguments.Add('/nr:false') + $msbuildVerbosity = if ([string]::IsNullOrWhiteSpace($env:OBSERVER_MSBUILD_VERBOSITY)) { + 'minimal' + } + else { + $env:OBSERVER_MSBUILD_VERBOSITY + } + $arguments.Add("/verbosity:$msbuildVerbosity") + $arguments.Add("/t:$Target") + $arguments.Add("/p:Configuration=$Configuration") + $arguments.Add("/p:Platform=$platform") + $arguments.Add("/p:VcpkgRoot=$($vcpkg.Root)") + foreach ($entry in $Properties.GetEnumerator()) { + $arguments.Add("/p:$($entry.Key)=$($entry.Value)") + } + + $previousMSBuild = [Environment]::GetEnvironmentVariable('OBSERVER_MSBUILD_EXE', 'Process') + try { + [Environment]::SetEnvironmentVariable('OBSERVER_MSBUILD_EXE', $visualStudio.MSBuild, 'Process') + Invoke-Native -FilePath (Join-Path $script:BuildRoot 'run-msbuild.cmd') -Arguments $arguments.ToArray() + } finally { + [Environment]::SetEnvironmentVariable('OBSERVER_MSBUILD_EXE', $previousMSBuild, 'Process') + } +} + +function Invoke-Restore { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter()][ValidateSet('default', 'asan', 'all')][string] $Flavor = 'default' + ) + + if ($SkipDependencyRestore) { + Write-Verbose 'Skipping dependency restore because -SkipDependencyRestore was explicitly requested.' + return + } + + $vcpkg = Resolve-Vcpkg + $overlayTriplets = Join-Path $script:BuildRoot 'vcpkg\triplets' + $requestedFlavors = if ($Flavor -eq 'all') { @('default', 'asan') } else { @($Flavor) } + + foreach ($requestedFlavor in $requestedFlavors) { + foreach ($architecture in $Architectures) { + if ($requestedFlavor -eq 'asan' -and $architecture -eq 'arm64') { + Write-Verbose 'Skipping the unsupported ARM64 ASan dependency flavor.' + continue + } + + $triplet = Get-VcpkgTriplet -Architecture $architecture -Flavor $requestedFlavor + $installRootSuffix = if ($requestedFlavor -eq 'asan') { "$architecture-asan" } else { $architecture } + $installRoot = Join-Path $script:ArtifactsRoot "vcpkg_installed\$installRootSuffix" + New-Item -ItemType Directory -Force -Path $installRoot | Out-Null + Write-Step "Restoring vcpkg dependencies for $architecture ($triplet)" + Invoke-Native -FilePath $vcpkg.Executable -Arguments @( + 'install', + "--triplet=$triplet", + "--overlay-triplets=$overlayTriplets", + "--x-manifest-root=$script:RepositoryRoot", + "--x-install-root=$installRoot" + ) + } + } +} + +function Invoke-Build { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter(Mandatory)][string] $Configuration, + [Parameter()][string] $Target = 'Build', + [Parameter()][hashtable] $Properties = @{} + ) + + foreach ($architecture in $Architectures) { + Write-Step "Building $Target for $architecture $Configuration" + Invoke-MSBuild -Target $Target -Architecture $architecture -Configuration $Configuration -Properties $Properties + } +} + +function Test-CanRunArchitecture { + param([Parameter(Mandatory)][string] $Architecture) + + return Test-VerifyArchitectureRunnable ` + -HostArchitecture (Get-CurrentVerifyHostArchitecture) ` + -TargetArchitecture $Architecture +} + +function Get-TestReportPath { + param( + [Parameter(Mandatory)][string] $Architecture, + [Parameter(Mandatory)][string] $Configuration, + [Parameter()][string] $Suite = 'tests' + ) + + $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\tests' + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null + return Join-Path $reportDirectory "$Suite-$Architecture-$($Configuration.ToLowerInvariant()).xml" +} + +function Invoke-TestExecutable { + param( + [Parameter(Mandatory)][string] $Architecture, + [Parameter(Mandatory)][string] $Configuration, + [Parameter()][string] $CorpusPath + ) + + if (-not (Test-CanRunArchitecture $Architecture)) { + throw "The current host cannot execute $Architecture test binaries. Use a native $Architecture CI runner." + } + + $binaryDirectory = Get-BinaryDirectory -Architecture $Architecture -Configuration $Configuration + $testExecutable = Join-Path $binaryDirectory 'tests.exe' + if (-not (Test-Path -LiteralPath $testExecutable)) { + throw "Test executable was not found: $testExecutable" + } + + $previousCorpus = [Environment]::GetEnvironmentVariable('OBSERVER_TEST_CORPUS', 'Process') + try { + if ($CorpusPath) { + $resolvedCorpus = Resolve-UserPath -Path $CorpusPath + if (-not (Test-Path -LiteralPath $resolvedCorpus -PathType Container)) { + throw "Corpus directory does not exist: $resolvedCorpus" + } + [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $resolvedCorpus, 'Process') + } + + Write-Step "Running tests for $Architecture $Configuration" + $testReportPath = Get-TestReportPath -Architecture $Architecture -Configuration $Configuration + Invoke-Native -FilePath $testExecutable -Arguments @( + '--reporter', 'compact', + '--reporter', "JUnit::out=$testReportPath", + '--durations', 'yes', + '--order', 'lex' + ) -WorkingDirectory $binaryDirectory + Write-Host "Report: $testReportPath" + if ($CorpusPath) { + Write-Step "Running external compatibility corpus for $Architecture $Configuration" + $compatibilityReportPath = Get-TestReportPath -Architecture $Architecture -Configuration $Configuration -Suite 'compatibility' + Invoke-Native -FilePath $testExecutable -Arguments @( + '[compatibility]', + '--reporter', 'compact', + '--reporter', "JUnit::out=$compatibilityReportPath", + '--durations', 'yes', + '--order', 'lex' + ) -WorkingDirectory $binaryDirectory + Write-Host "Report: $compatibilityReportPath" + } + } finally { + [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $previousCorpus, 'Process') + } +} + +function Invoke-Test { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter(Mandatory)][string] $Configuration, + [Parameter()][string] $CorpusPath + ) + + Invoke-Build -Architectures $Architectures -Configuration $Configuration + foreach ($architecture in $Architectures) { + Invoke-TestExecutable -Architecture $architecture -Configuration $Configuration -CorpusPath $CorpusPath + } +} + +function Invoke-FormatCheck { + $llvm = Resolve-Llvm + $files = @( + Get-ChildItem -Recurse -File -LiteralPath (Join-Path $script:RepositoryRoot 'src') -Include '*.cpp', '*.h', '*.hpp' | + Select-Object -ExpandProperty FullName + ) + Write-Step 'Checking C++ formatting' + Invoke-Native -FilePath $llvm.Format -Arguments (@('--dry-run', '--Werror') + $files) +} + +function Invoke-Cppcheck { + param([Parameter(Mandatory)][string[]] $Architectures) + + $cppcheck = Resolve-Cppcheck + $sourceDirectory = Join-Path $script:RepositoryRoot 'src' + $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\cppcheck' + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null + + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($architecture in $Architectures) { + $platform = if ($architecture -eq 'x86') { 'win32W' } else { 'win64' } + $architectureDefine = switch ($architecture) { + 'x86' { '_M_IX86=600' } + 'x64' { '_M_X64=100' } + 'arm64' { '_M_ARM64=1' } + } + + $triplet = Get-VcpkgTriplet -Architecture $architecture + $vcpkgIncludeDirectory = Join-Path $script:ArtifactsRoot "vcpkg_installed\$architecture\$triplet\include" + if (-not (Test-Path -LiteralPath $vcpkgIncludeDirectory -PathType Container)) { + throw "Cppcheck dependency headers were not restored for ${architecture}: $vcpkgIncludeDirectory" + } + $buildDirectory = Join-Path $script:ArtifactsRoot "cppcheck\$architecture" + $reportPath = Join-Path $reportDirectory "cppcheck-$architecture.sarif" + New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null + + Write-Step "Cppcheck: all C++ sources for $architecture" + try { + Invoke-Native -FilePath $cppcheck -Arguments @( + $sourceDirectory, + '--std=c++23', + "--platform=$platform", + '-DWIN32=1', + '-D_WIN32=1', + '-DUNICODE=1', + '-D_UNICODE=1', + "-D$architectureDefine", + "-I$sourceDirectory", + "-I$vcpkgIncludeDirectory", + '--enable=warning,style,performance,portability', + '--check-level=exhaustive', + '--inconclusive', + '--inline-suppr', + '--error-exitcode=1', + '--suppress=missingIncludeSystem', + '--suppress=uninitMemberVarNoCtor:src/api.h', + # Dependency diagnostics belong to their upstream projects; keep every enabled rule active for first-party sources. + '--suppress=*:.artifacts/vcpkg_installed/*', + # Each plugin currently supplies one non-polymorphic extractor implementation; this changes in the parser-core refactor. + '--suppress=functionStatic', + "--relative-paths=$script:RepositoryRoot", + '--output-format=sarif', + "--output-file=$reportPath", + "--cppcheck-build-dir=$buildDirectory" + ) + } catch { + $failures.Add("${architecture}: $($_.Exception.Message)") + } finally { + if (Test-Path -LiteralPath $reportPath) { + Write-Host "Report: $reportPath" + } + } + } + + if ($failures.Count -gt 0) { + throw "Cppcheck failed:`n$($failures -join "`n")" + } +} + +function Invoke-PowerShellAnalysis { + $module = Get-Module -ListAvailable PSScriptAnalyzer | + Sort-Object Version -Descending | + Select-Object -First 1 + if (-not $module) { + throw 'PSScriptAnalyzer was not found. Install-Module PSScriptAnalyzer -Scope CurrentUser.' + } + + Import-Module $module.Path + $settings = Join-Path $script:BuildRoot 'PSScriptAnalyzerSettings.psd1' + $diagnostics = @( + Invoke-ScriptAnalyzer -Path (Join-Path $script:RepositoryRoot 'build.ps1') -Settings $settings + Invoke-ScriptAnalyzer -Path $script:BuildRoot -Recurse -Settings $settings + ) + + $rules = @( + $diagnostics | + Group-Object RuleName | + ForEach-Object { + [ordered]@{ + id = $_.Name + name = $_.Name + shortDescription = [ordered]@{ text = $_.Group[0].Message } + } + } + ) + $results = @( + foreach ($diagnostic in $diagnostics) { + $level = switch ($diagnostic.Severity.ToString()) { + 'Error' { 'error' } + 'Warning' { 'warning' } + default { 'note' } + } + $relativePath = [System.IO.Path]::GetRelativePath($script:RepositoryRoot, $diagnostic.ScriptPath).Replace('\', '/') + [ordered]@{ + ruleId = $diagnostic.RuleName + level = $level + message = [ordered]@{ text = $diagnostic.Message } + locations = @( + [ordered]@{ + physicalLocation = [ordered]@{ + artifactLocation = [ordered]@{ uri = $relativePath } + region = [ordered]@{ + startLine = [int] $diagnostic.Line + startColumn = [int] $diagnostic.Column + } + } + } + ) + } + } + ) + $sarif = [ordered]@{ + version = '2.1.0' + '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' + runs = @( + [ordered]@{ + tool = [ordered]@{ + driver = [ordered]@{ + name = 'PSScriptAnalyzer' + version = $module.Version.ToString() + informationUri = 'https://github.com/PowerShell/PSScriptAnalyzer' + rules = $rules + } + } + results = $results + } + ) + } + $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\psscriptanalyzer' + $reportPath = Join-Path $reportDirectory 'psscriptanalyzer.sarif' + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null + $sarif | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 + Write-Host "Report: $reportPath" + + if ($diagnostics.Count -gt 0) { + Write-Host ($diagnostics | Format-Table -AutoSize | Out-String) + throw "PSScriptAnalyzer reported $($diagnostics.Count) diagnostic(s)." + } +} + +function Invoke-BuildContractTest { + $testDirectory = Join-Path $script:BuildRoot 'tests' + $tests = @(Get-ChildItem -LiteralPath $testDirectory -File -Filter '*.Tests.ps1' | Sort-Object Name) + if ($tests.Count -eq 0) { + throw "No build contract tests were found in '$testDirectory'." + } + + Write-Step "Running $($tests.Count) build contract test(s)" + foreach ($test in $tests) { + & $test.FullName + } +} + +function Invoke-Lint { + param([Parameter(Mandatory)][string[]] $Architectures) + + $requestedArchitectures = $Architectures + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($check in @( + @{ Name = 'clang-format'; Action = { Invoke-FormatCheck } }, + @{ Name = 'Cppcheck'; Action = { Invoke-Cppcheck -Architectures $requestedArchitectures } }, + @{ Name = 'build contracts'; Action = { Invoke-BuildContractTest } }, + @{ Name = 'PSScriptAnalyzer'; Action = { + Write-Step 'Checking PowerShell sources' + Invoke-PowerShellAnalysis + } } + )) { + try { + & $check.Action + } catch { + $failures.Add("$($check.Name): $($_.Exception.Message)") + } + } + + if ($failures.Count -gt 0) { + throw "Source checks failed:`n$($failures -join "`n")" + } +} + +function Invoke-CodeAnalysis { + param([Parameter(Mandatory)][string[]] $Architectures) + + $llvm = Resolve-Llvm + $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\msvc' + foreach ($architecture in $Architectures) { + New-Item -ItemType Directory -Force -Path (Join-Path $reportDirectory $architecture) | Out-Null + } + try { + Invoke-Build -Architectures $Architectures -Configuration 'Debug' -Target 'Rebuild' -Properties @{ + ObserverRunCodeAnalysis = 'true' + ObserverAnalysisReportDirectory = $reportDirectory + ObserverEnableClangTidy = 'true' + LLVMInstallDir = $llvm.Root + } + } finally { + foreach ($architecture in $Architectures) { + Set-MSVCAnalysisSarifIdentity ` + -ReportDirectory (Join-Path $reportDirectory $architecture) ` + -Architecture $architecture + + $clangTidyReportPath = Join-Path ` + $script:ArtifactsRoot ` + "reports\clang-tidy\$architecture\clang-tidy.sarif" + Export-ClangTidySarif ` + -RepositoryRoot $script:RepositoryRoot ` + -ObjectRoot (Join-Path $script:ArtifactsRoot "obj\$architecture") ` + -OutputPath $clangTidyReportPath ` + -Architecture $architecture + Write-Host "clang-tidy SARIF report: $clangTidyReportPath" + } + } + Write-Host "MSVC SARIF reports: $reportDirectory" +} + +function Add-ASanRuntimeToPath { + param([Parameter(Mandatory)][string] $Architecture) + + $visualStudio = Resolve-VisualStudio + $targetDirectory = if ($Architecture -eq 'x86') { 'x86' } else { 'x64' } + $runtimeDirectory = Join-Path $visualStudio.ToolsetDirectory "bin\Hostx64\$targetDirectory" + $runtime = Get-ChildItem -File -LiteralPath $runtimeDirectory -Filter 'clang_rt.asan_dynamic-*.dll' | Select-Object -First 1 + if (-not $runtime) { + throw "The MSVC AddressSanitizer runtime was not found in '$runtimeDirectory'." + } + [Environment]::SetEnvironmentVariable('PATH', "$runtimeDirectory;$env:PATH", 'Process') +} + +function Invoke-ASan { + param([Parameter(Mandatory)][string[]] $Architectures) + + foreach ($architecture in $Architectures) { + if ($architecture -eq 'arm64') { + throw 'MSVC AddressSanitizer does not support ARM64. Use x86 or x64.' + } + } + + Invoke-Restore -Architectures $Architectures -Flavor 'asan' + Invoke-Build -Architectures $Architectures -Configuration 'ASan' + $previousOptions = [Environment]::GetEnvironmentVariable('ASAN_OPTIONS', 'Process') + try { + [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', 'halt_on_error=1:alloc_dealloc_mismatch=1', 'Process') + foreach ($architecture in $Architectures) { + Add-ASanRuntimeToPath -Architecture $architecture + Invoke-TestExecutable -Architecture $architecture -Configuration 'ASan' + } + } finally { + [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', $previousOptions, 'Process') + } +} + +function Invoke-Ubsan { + param([Parameter(Mandatory)][string[]] $Architectures) + + foreach ($architecture in $Architectures) { + if ($architecture -ne 'x64') { + throw 'The clang-cl UBSan configuration is intentionally x64-only. Release and MSVC test builds still cover x86, x64, and ARM64.' + } + + $llvm = Resolve-Llvm + $llvmRuntimeDirectory = Get-ChildItem -Directory -Path (Join-Path $llvm.Root 'lib\clang\*\lib\windows') | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $llvmRuntimeDirectory) { + throw "LLVM sanitizer runtimes were not found under '$($llvm.Root)'." + } + + Invoke-Restore -Architectures @($architecture) + Write-Step "Building tests with clang-cl UBSan for $architecture" + Invoke-MSBuild -Target 'Build' -Architecture $architecture -Configuration 'UBSan' -Properties @{ + LLVMInstallDir = $llvm.Root + LLVMRuntimeDir = $llvmRuntimeDirectory.FullName + } + + $previousOptions = [Environment]::GetEnvironmentVariable('UBSAN_OPTIONS', 'Process') + try { + [Environment]::SetEnvironmentVariable('UBSAN_OPTIONS', 'halt_on_error=1:print_stacktrace=1', 'Process') + Invoke-TestExecutable -Architecture $architecture -Configuration 'UBSan' + } finally { + [Environment]::SetEnvironmentVariable('UBSAN_OPTIONS', $previousOptions, 'Process') + } + } +} + +function Read-LeakProbeMarker { + param( + [Parameter(Mandatory)][System.Diagnostics.Process] $Process, + [Parameter(Mandatory)][string] $Marker, + [Parameter()][string] $Label + ) + + while ($true) { + try { + $line = $Process.StandardOutput.ReadLineAsync().WaitAsync([TimeSpan]::FromSeconds(60)).GetAwaiter().GetResult() + } catch [TimeoutException] { + throw "Leak probe produced no $Marker $Label marker within 60 seconds." + } + if ($null -eq $line) { + $errorOutput = $Process.StandardError.ReadToEnd() + throw "Leak probe exited before $Marker $Label. $errorOutput" + } + Write-Host $line + if ($line.StartsWith('OBSERVER_LEAK_PROBE|ERROR|', [StringComparison]::Ordinal)) { + throw $line + } + + $expectedPrefix = if ($Label) { + "OBSERVER_LEAK_PROBE|$Marker|$Label|" + } else { + "OBSERVER_LEAK_PROBE|$Marker|" + } + if ($line.StartsWith($expectedPrefix, [StringComparison]::Ordinal)) { + return $line + } + } +} + +function Invoke-LeakProbePreflight { + param( + [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, + [Parameter(Mandatory)][string] $Probe, + [Parameter(Mandatory)][string] $BinaryDirectory + ) + + $expectedScenarios = 'small-success,malformed,cancellation,read-failure,write-failure,large-metadata,sparse-metadata' + $output = Invoke-NativeCapture -FilePath $Probe -Arguments @( + '--automatic', + '--mode', $Mode, + '--warmup', '1', + '--iterations', '1', + '--windows', '3' + ) -WorkingDirectory $BinaryDirectory + $ready = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|READY|', [StringComparison]::Ordinal) }) + if ($ready.Count -ne 1 -or + $ready[0] -notmatch "\|mode=$Mode\|configuration=Release\|scenarios=$([regex]::Escape($expectedScenarios))$") { + throw "Leak probe $Mode preflight did not report the required Release scenario contract." + } + if (@($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|DONE|', [StringComparison]::Ordinal) }).Count -ne 1) { + throw "Leak probe $Mode preflight did not complete." + } +} + +function Assert-UmdhSnapshotUsable { + param([Parameter(Mandatory)][string] $Path) + + $snapshot = Get-Content -Raw -LiteralPath $Path + if ($snapshot -match "didn't find any allocations|database is full|stack trace database.*full") { + throw "UMDH snapshot reports unusable allocation-stack data: $Path" + } + if ($snapshot -notmatch 'BackTrace') { + throw "UMDH snapshot contains no allocation backtraces: $Path" + } +} + +function Compare-UmdhSnapshot { + param( + [Parameter(Mandatory)][string] $Umdh, + [Parameter(Mandatory)][string] $Before, + [Parameter(Mandatory)][string] $After, + [Parameter(Mandatory)][string] $Output, + [Parameter(Mandatory)][string] $Label + ) + + [void](Invoke-Native -FilePath $Umdh -Arguments @('-d', $Before, $After, "-f:$Output")) + $lines = @(Get-Content -LiteralPath $Output) + $totalLine = $lines | Where-Object { $_ -match '^Total (increase|decrease)\s*==' } | Select-Object -Last 1 + if (-not $totalLine -or $totalLine -notmatch '^Total (increase|decrease)\s*==\s*([0-9]+)') { + throw "UMDH comparison did not contain a total allocation delta: $Output" + } + $direction = $Matches[1] + $totalIncrease = [int64]::Parse($Matches[2], [Globalization.CultureInfo]::InvariantCulture) + if ($direction -eq 'decrease') { + $totalIncrease = -$totalIncrease + } + + $positiveStacks = @{} + foreach ($line in $lines) { + if ($line -match '^\+\s+([0-9]+)\s+\([^)]*\)\s+[0-9]+\s+allocs\s+BackTrace\s*([0-9A-Fa-f]+)') { + $positiveStacks[$Matches[2]] = [int64]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) + } + } + + return [pscustomobject]@{ + Label = $Label + TotalIncrease = $totalIncrease + PositiveStacks = $positiveStacks + Report = $Output + } +} + +function Invoke-LeakProbeMode { + param( + [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, + [Parameter(Mandatory)][string] $Probe, + [Parameter(Mandatory)][string] $BinaryDirectory, + [Parameter(Mandatory)] $DebuggingTools, + [Parameter(Mandatory)][string] $ReportDirectory, + [Parameter(Mandatory)][int] $Warmup, + [Parameter(Mandatory)][int] $Iterations, + [Parameter(Mandatory)][int] $Windows, + [Parameter(Mandatory)][int64] $ToleranceBytes + ) + + $modeDirectory = Join-Path $ReportDirectory $Mode + New-Item -ItemType Directory -Force -Path $modeDirectory | Out-Null + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Probe + $startInfo.WorkingDirectory = $BinaryDirectory + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + foreach ($argument in @('--mode', $Mode, '--warmup', $Warmup, '--iterations', $Iterations, '--windows', $Windows)) { + $startInfo.ArgumentList.Add([string]$argument) + } + $startInfo.Environment['_NT_SYMBOL_PATH'] = $BinaryDirectory + $startInfo.Environment['OANOCACHE'] = '1' + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $snapshots = [System.Collections.Generic.List[string]]::new() + $started = $false + try { + if (-not $process.Start()) { + throw "Failed to start leak probe: $Probe" + } + $started = $true + [void](Read-LeakProbeMarker -Process $process -Marker 'READY') + + $labels = @('baseline') + @(1..$Windows | ForEach-Object { "window-$_" }) + foreach ($label in $labels) { + $marker = Read-LeakProbeMarker -Process $process -Marker 'SNAPSHOT' -Label $label + if ($marker -notmatch '\|pid=([0-9]+)\|') { + throw "Leak probe snapshot marker has no PID: $marker" + } + $reportedPid = [int]$Matches[1] + if ($reportedPid -ne $process.Id) { + throw "Leak probe reported PID $reportedPid, but the started process is $($process.Id)." + } + + $snapshot = Join-Path $modeDirectory "$label.txt" + if ($label -eq 'baseline') { + & $DebuggingTools.Umdh "-p:$($process.Id)" "-f:$snapshot" + $snapshotExitCode = $LASTEXITCODE + $snapshotText = if (Test-Path -LiteralPath $snapshot) { + Get-Content -Raw -LiteralPath $snapshot + } else { + '' + } + if ($snapshotExitCode -notin @(0, 1) -or + ($snapshotExitCode -eq 1 -and $snapshotText -notmatch 'enabled allocation stack collection')) { + throw "UMDH could not prime allocation stack collection for PID $($process.Id) (exit $snapshotExitCode)." + } + } else { + Invoke-Native -FilePath $DebuggingTools.Umdh -Arguments @("-p:$($process.Id)", "-f:$snapshot") + Assert-UmdhSnapshotUsable -Path $snapshot + } + $snapshots.Add($snapshot) + $process.StandardInput.WriteLine("continue|$label") + $process.StandardInput.Flush() + } + + [void](Read-LeakProbeMarker -Process $process -Marker 'DONE') + if (-not $process.WaitForExit(120000)) { + throw "Leak probe did not exit after its final snapshot ($Mode)." + } + $errorOutput = $process.StandardError.ReadToEnd() + if ($process.ExitCode -ne 0) { + throw "Leak probe failed with exit code $($process.ExitCode): $errorOutput" + } + if ($errorOutput) { + Write-Host $errorOutput + } + } finally { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() + } + + # The first UMDH attachment enables per-process allocation stack collection without requiring an elevated, + # persistent GFlags registry setting. It is deliberately a priming snapshot. window-1 becomes the measured + # baseline after another complete workload window has run with stack collection active. + $analysisSnapshots = @($snapshots | Select-Object -Skip 1) + $comparisons = [System.Collections.Generic.List[object]]::new() + for ($index = 1; $index -lt $analysisSnapshots.Count; ++$index) { + $label = "window-$index" + $comparisonPath = Join-Path $modeDirectory "growth-$label.txt" + $comparisons.Add((Compare-UmdhSnapshot -Umdh $DebuggingTools.Umdh -Before $analysisSnapshots[$index - 1] -After $analysisSnapshots[$index] -Output $comparisonPath -Label $label)) + } + $overallPath = Join-Path $modeDirectory 'growth-overall.txt' + $overall = Compare-UmdhSnapshot -Umdh $DebuggingTools.Umdh -Before $analysisSnapshots[0] -After $analysisSnapshots[$analysisSnapshots.Count - 1] -Output $overallPath -Label 'overall' + + $last = $comparisons[$comparisons.Count - 1] + $previous = $comparisons[$comparisons.Count - 2] + $repeatedGrowingStacks = @( + $last.PositiveStacks.Keys | Where-Object { + $previous.PositiveStacks.ContainsKey($_) -and + $last.PositiveStacks[$_] -gt $ToleranceBytes -and + $previous.PositiveStacks[$_] -gt $ToleranceBytes + } + ) + $sustainedTotalGrowth = $last.TotalIncrease -gt $ToleranceBytes -and + $previous.TotalIncrease -gt $ToleranceBytes -and $overall.TotalIncrease -gt (2 * $ToleranceBytes) + + $summary = [ordered]@{ + mode = $Mode + warmupRounds = $Warmup + iterationsPerWindow = $Iterations + windows = $Windows + initialSnapshot = 'UMDH stack-collection priming only' + measuredBaseline = 'window-1' + toleranceBytes = $ToleranceBytes + totalGrowthByWindow = @($comparisons | ForEach-Object { $_.TotalIncrease }) + overallGrowthBytes = $overall.TotalIncrease + repeatedGrowingStacks = $repeatedGrowingStacks + passed = -not $sustainedTotalGrowth -and $repeatedGrowingStacks.Count -eq 0 + } + $summaryPath = Join-Path $modeDirectory 'summary.json' + $summary | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $summaryPath -Encoding utf8 + if (-not $summary.passed) { + throw "UMDH found sustained heap growth in $Mode mode. Summary: $summaryPath" + } + Write-Host "[OK] UMDH $Mode mode: no sustained growth. Summary: $summaryPath" +} + +function Invoke-LeakTest { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter(Mandatory)][int] $Warmup, + [Parameter(Mandatory)][int] $Iterations, + [Parameter(Mandatory)][int] $Windows, + [Parameter(Mandatory)][int64] $ToleranceBytes + ) + + if ($Architectures.Count -ne 1 -or $Architectures[0] -ne 'x64') { + throw 'UMDH leak testing is intentionally x64-only. Use -Arch x64.' + } + + $debuggingTools = Resolve-WindowsDebuggingTool + Invoke-Restore -Architectures @('x64') + Write-Step 'Building the shipping x64 Release /MT leak probe and module DLLs' + Invoke-MSBuild -Target 'BuildLeakProbe' -Architecture 'x64' -Configuration 'Release' + $binaryDirectory = Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release' + $probe = Join-Path $binaryDirectory 'leak-probe.exe' + if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { + throw "Leak probe was not found: $probe" + } + + $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\leaks\x64' + if (Test-Path -LiteralPath $reportDirectory) { + Remove-Item -Recurse -Force -LiteralPath $reportDirectory + } + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null + + $moduleEvidence = @( + foreach ($moduleName in $script:ModuleNames) { + Assert-ReleaseBinary -Architecture 'x64' -ModuleName $moduleName + $modulePath = Join-Path $binaryDirectory "$moduleName.so" + [ordered]@{ + module = $moduleName + path = $modulePath + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $modulePath).Hash + } + } + ) + $binaryEvidence = [ordered]@{ + architecture = 'x64' + configuration = 'Release' + runtimeLibrary = 'MT_StaticRelease' + probe = [ordered]@{ + path = $probe + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $probe).Hash + } + modules = $moduleEvidence + } + $binaryEvidence | ConvertTo-Json -Depth 6 | + Set-Content -LiteralPath (Join-Path $reportDirectory 'release-binaries.json') -Encoding utf8 + + foreach ($mode in @('operations', 'lifecycle')) { + Write-Step "Release leak-probe preflight: $mode" + Invoke-LeakProbePreflight -Mode $mode -Probe $probe -BinaryDirectory $binaryDirectory + } + + $previousSymbolPath = [Environment]::GetEnvironmentVariable('_NT_SYMBOL_PATH', 'Process') + try { + [Environment]::SetEnvironmentVariable('_NT_SYMBOL_PATH', $binaryDirectory, 'Process') + foreach ($mode in @('operations', 'lifecycle')) { + Write-Step "UMDH leak test: $mode" + Invoke-LeakProbeMode -Mode $mode -Probe $probe -BinaryDirectory $binaryDirectory -DebuggingTools $debuggingTools -ReportDirectory $reportDirectory -Warmup $Warmup -Iterations $Iterations -Windows $Windows -ToleranceBytes $ToleranceBytes + } + } finally { + [Environment]::SetEnvironmentVariable('_NT_SYMBOL_PATH', $previousSymbolPath, 'Process') + } +} + +function Initialize-FuzzCorpus { + param( + [Parameter(Mandatory)][string] $SeedDirectory, + [Parameter(Mandatory)][string] $CorpusDirectory + ) + + if (-not (Test-Path -LiteralPath $SeedDirectory -PathType Container)) { + throw "Fuzzer seed directory was not found: $SeedDirectory" + } + New-Item -ItemType Directory -Force -Path $CorpusDirectory | Out-Null + + foreach ($seed in Get-ChildItem -LiteralPath $SeedDirectory -File) { + if ($seed.Extension -eq '.hex') { + $hex = (Get-Content -LiteralPath $seed.FullName -Raw) -replace '\s', '' + if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { + throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" + } + $destination = Join-Path $CorpusDirectory $seed.BaseName + [System.IO.File]::WriteAllBytes($destination, [Convert]::FromHexString($hex)) + } else { + Copy-Item -LiteralPath $seed.FullName -Destination $CorpusDirectory -Force + } + } +} + +function Invoke-Fuzz { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter(Mandatory)][int] $Seconds, + [Parameter(Mandatory)][string] $TargetName + ) + + foreach ($architecture in $Architectures) { + if ($architecture -ne 'x64') { + throw 'The LLVM libFuzzer configuration is intentionally x64-only. Release and MSVC test builds still cover x86, x64, and ARM64.' + } + + $llvm = Resolve-Llvm + $llvmRuntimeDirectory = Get-ChildItem -Directory -Path (Join-Path $llvm.Root 'lib\clang\*\lib\windows') | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $llvmRuntimeDirectory) { + throw "LLVM sanitizer runtimes were not found under '$($llvm.Root)'." + } + + Invoke-Restore -Architectures @($architecture) -Flavor 'asan' + Write-Step "Building all parser fuzzers for $architecture" + Invoke-MSBuild -Target 'BuildFuzz' -Architecture $architecture -Configuration 'Fuzz' -Properties @{ + LLVMInstallDir = $llvm.Root + LLVMRuntimeDir = $llvmRuntimeDirectory.FullName + } + + [Environment]::SetEnvironmentVariable('PATH', "$($llvmRuntimeDirectory.FullName);$env:PATH", 'Process') + + $targets = @( + [pscustomobject]@{ Name = 'pickle'; MaxLength = 262144 }, + [pscustomobject]@{ Name = 'renpy'; MaxLength = 1048576 }, + [pscustomobject]@{ Name = 'rpgmaker'; MaxLength = 1048576 }, + [pscustomobject]@{ Name = 'zanzarah'; MaxLength = 1048576 } + ) + if ($TargetName -ne 'all') { + $targets = @($targets | Where-Object Name -eq $TargetName) + } + $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Fuzz' + $previousOptions = [Environment]::GetEnvironmentVariable('ASAN_OPTIONS', 'Process') + try { + [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', 'halt_on_error=1:alloc_dealloc_mismatch=1', 'Process') + foreach ($target in $targets) { + $fuzzer = Join-Path $binaryDirectory "fuzz-$($target.Name).exe" + if (-not (Test-Path -LiteralPath $fuzzer -PathType Leaf)) { + throw "Fuzzer executable was not found: $fuzzer" + } + + $seedCorpusDirectory = Join-Path $script:RepositoryRoot "src\fuzz\corpus\$($target.Name)" + $fuzzDirectory = Join-Path $script:ArtifactsRoot "fuzz\$architecture\$($target.Name)" + $corpusDirectory = Join-Path $fuzzDirectory 'corpus' + $artifactDirectory = Join-Path $fuzzDirectory 'artifacts' + if (Test-Path -LiteralPath $artifactDirectory) { + Remove-Item -Recurse -Force -LiteralPath $artifactDirectory + } + New-Item -ItemType Directory -Force -Path $artifactDirectory | Out-Null + + $seedReplayDirectory = Join-Path $fuzzDirectory "seed-replay\$([Guid]::NewGuid().ToString('N'))" + Initialize-FuzzCorpus -SeedDirectory $seedCorpusDirectory -CorpusDirectory $seedReplayDirectory + $seedInputs = @(Get-ChildItem -LiteralPath $seedReplayDirectory -File | Sort-Object Name) + if ($seedInputs.Count -eq 0) { + throw "No checked-in fuzzer seeds were found for $($target.Name)." + } + + Write-Step "Replaying $($seedInputs.Count) checked-in $($target.Name) seed(s)" + Invoke-Native -FilePath $fuzzer -Arguments (@($seedInputs.FullName) + @( + "-max_len=$($target.MaxLength)", + '-rss_limit_mb=1024', + '-timeout=10', + '-print_final_stats=1', + "-artifact_prefix=$artifactDirectory\" + )) -WorkingDirectory $binaryDirectory + + Initialize-FuzzCorpus -SeedDirectory $seedCorpusDirectory -CorpusDirectory $corpusDirectory + + Write-Step "Fuzzing $($target.Name) for $Seconds second(s)" + Invoke-Native -FilePath $fuzzer -Arguments @( + $corpusDirectory, + "-max_total_time=$Seconds", + "-max_len=$($target.MaxLength)", + '-rss_limit_mb=1024', + '-timeout=10', + '-use_value_profile=1', + '-print_final_stats=1', + "-artifact_prefix=$artifactDirectory\" + ) -WorkingDirectory $binaryDirectory + } + } finally { + [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', $previousOptions, 'Process') + } + } +} + +function Invoke-Coverage { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter()][string] $CorpusPath, + [Parameter(Mandatory)][double] $Threshold + ) + + $llvm = Resolve-Llvm + $ignoredSources = '([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])' + + foreach ($architecture in $Architectures) { + if (-not (Test-CanRunArchitecture $architecture)) { + throw "The current host cannot execute $architecture coverage binaries." + } + + Invoke-Build -Architectures @($architecture) -Configuration 'Coverage' -Target 'Rebuild' -Properties @{ + LLVMInstallDir = $llvm.Root + } + $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Coverage' + $coverageDirectory = Join-Path $script:ArtifactsRoot "coverage\$architecture" + $runDirectory = Join-Path $coverageDirectory ([Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null + $rawProfilePattern = Join-Path $runDirectory 'observer-%m-%p.profraw' + $profilePath = Join-Path $coverageDirectory 'coverage.profdata' + $jsonReportPath = Join-Path $coverageDirectory 'coverage.json' + $lcovReportPath = Join-Path $coverageDirectory 'coverage.lcov' + $testExecutable = Join-Path $binaryDirectory 'tests.exe' + $coverageObjects = @($script:ModuleNames | ForEach-Object { Join-Path $binaryDirectory "$_.so" }) + + $previousCorpus = [Environment]::GetEnvironmentVariable('OBSERVER_TEST_CORPUS', 'Process') + $previousProfile = [Environment]::GetEnvironmentVariable('LLVM_PROFILE_FILE', 'Process') + try { + if ($CorpusPath) { + [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', (Resolve-UserPath -Path $CorpusPath), 'Process') + } + [Environment]::SetEnvironmentVariable('LLVM_PROFILE_FILE', $rawProfilePattern, 'Process') + Write-Step "Collecting LLVM source coverage for $architecture" + $testReportPath = Get-TestReportPath -Architecture $architecture -Configuration 'Coverage' + Invoke-Native -FilePath $testExecutable -Arguments @( + '--reporter', 'compact', + '--reporter', "JUnit::out=$testReportPath", + '--durations', 'yes', + '--order', 'lex' + ) -WorkingDirectory $binaryDirectory + Write-Host "Test report: $testReportPath" + } finally { + [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $previousCorpus, 'Process') + [Environment]::SetEnvironmentVariable('LLVM_PROFILE_FILE', $previousProfile, 'Process') + } + + $rawProfiles = @(Get-ChildItem -File -LiteralPath $runDirectory -Filter '*.profraw') + if ($rawProfiles.Count -eq 0) { + throw "The instrumented test run produced no LLVM raw profiles in '$runDirectory'." + } + + $mergeArguments = @('merge', '-sparse') + @($rawProfiles.FullName) + @('-o', $profilePath) + Invoke-Native -FilePath $llvm.Profdata -Arguments $mergeArguments + + $objectArguments = @($coverageObjects | ForEach-Object { @('--object', $_) }) + $commonArguments = @( + $testExecutable, + '--instr-profile', $profilePath, + '--ignore-filename-regex', $ignoredSources + ) + $objectArguments + + $summaryJson = Invoke-NativeCapture -FilePath $llvm.Cov -Arguments (@('export') + $commonArguments + @('--summary-only')) + $summaryText = @($summaryJson | Where-Object { $_ -notmatch '^warning:' }) -join "`n" + Set-Content -LiteralPath $jsonReportPath -Value $summaryText -Encoding utf8 + $report = $summaryText | ConvertFrom-Json + $totals = $report.data[0].totals + if ($totals.branches.count -eq 0) { + throw "LLVM coverage report contains no source branches: $jsonReportPath" + } + + Invoke-Native -FilePath $llvm.Cov -Arguments (@('report') + $commonArguments + @('--show-branch-summary')) + $lcov = Invoke-NativeCapture -FilePath $llvm.Cov -Arguments (@('export') + $commonArguments + @('--format=lcov')) + $lcovText = @($lcov | Where-Object { $_ -notmatch '^warning:' }) -join "`n" + Set-Content -LiteralPath $lcovReportPath -Value $lcovText -Encoding utf8 + + $reportedMetrics = @( + [pscustomobject]@{ Name = 'branches'; Value = $totals.branches }, + [pscustomobject]@{ Name = 'functions'; Value = $totals.functions }, + [pscustomobject]@{ Name = 'lines'; Value = $totals.lines }, + [pscustomobject]@{ Name = 'regions'; Value = $totals.regions } + ) + $requiredMetrics = @($reportedMetrics | Where-Object { $_.Name -in @('branches', 'lines') }) + $failedMetrics = @($requiredMetrics | Where-Object { $_.Value.percent -lt $Threshold }) + + Write-Host "Reports: $jsonReportPath, $lcovReportPath" + if ($failedMetrics.Count -gt 0) { + $failures = $failedMetrics | ForEach-Object { "{0}={1:N2}%" -f $_.Name, $_.Value.percent } + throw "Coverage is below the required $Threshold%: $($failures -join ', ')." + } + } +} + +function Assert-ReleaseBinary { + param( + [Parameter(Mandatory)][string] $Architecture, + [Parameter(Mandatory)][string] $ModuleName + ) + + $visualStudio = Resolve-VisualStudio + $binary = Join-Path (Get-BinaryDirectory -Architecture $Architecture -Configuration 'Release') "$ModuleName.so" + if (-not (Test-Path -LiteralPath $binary)) { + throw "Release module was not found: $binary" + } + + $headers = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/headers', $binary) + $expectedMachine = switch ($Architecture) { + 'x86' { '14C machine \(x86\)' } + 'x64' { '8664 machine \(x64\)' } + 'arm64' { 'AA64 machine \(ARM64\)' } + } + if (($headers -join "`n") -notmatch $expectedMachine) { + throw "$ModuleName has the wrong PE machine type for $Architecture." + } + + $dependencyOutput = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/dependents', $binary) + $dependencies = @( + $dependencyOutput | + ForEach-Object { + if ($_ -match '^\s+([A-Za-z0-9._-]+\.dll)\s*$') { $Matches[1] } + } | + Select-Object -Unique + ) + $allowedDependencies = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + @('KERNEL32.dll', 'USER32.dll', 'ADVAPI32.dll', 'SHELL32.dll', 'OLE32.dll', 'OLEAUT32.dll', 'SHLWAPI.dll', 'BCRYPT.dll', 'NTDLL.dll') | + ForEach-Object { [void]$allowedDependencies.Add($_) } + $forbiddenRuntimePattern = '^(VCRUNTIME|MSVCP|UCRTBASE|api-ms-win-crt-|ext-ms-win-crt-|zlib|zstd|xxhash|clang_rt\.).*\.dll$' + $unexpectedDependencies = @( + $dependencies | Where-Object { + $_ -match $forbiddenRuntimePattern -or + (-not $allowedDependencies.Contains($_) -and $_ -notmatch '^(api|ext)-ms-win-.*\.dll$') + } + ) + if ($unexpectedDependencies.Count -gt 0) { + throw "$ModuleName has non-system DLL dependencies: $($unexpectedDependencies -join ', ')" + } + + $exportsOutput = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/exports', $binary) + $exports = @( + $exportsOutput | ForEach-Object { + if ($_ -match '^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)') { $Matches[1] } + } + ) + $expectedExports = @('LoadSubModule', 'UnloadSubModule') + $exportDifference = @( + Compare-Object -ReferenceObject $expectedExports -DifferenceObject $exports | + ForEach-Object { "$($_.SideIndicator)$($_.InputObject)" } + ) + if ($exportDifference.Count -gt 0) { + throw "$ModuleName has an unexpected export surface: $($exportDifference -join ', ')" + } + + Write-Host "[OK] $ModuleName.so: $Architecture, static runtime/dependencies, expected Observer exports" +} + +function Invoke-BinSkimAudit { + param([Parameter(Mandatory)][string] $Architecture) + + $binSkim = Resolve-BinSkim + $binaryDirectory = Get-BinaryDirectory -Architecture $Architecture -Configuration 'Release' + $reportDirectory = Join-Path $script:ArtifactsRoot 'audit' + $reportPath = Join-Path $reportDirectory "binskim-$Architecture.sarif" + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null + + $targets = @($script:ModuleNames | ForEach-Object { Join-Path $binaryDirectory "$_.so" }) + $arguments = @( + 'analyze' + ) + $targets + @( + '--level', 'Error;Warning', + '--kind', 'Fail', + '--local-symbol-directories', $binaryDirectory, + '--output', $reportPath, + '--log', 'ForceOverwrite', + '--quiet', + '--disable-telemetry' + ) + Invoke-Native -FilePath $binSkim -Arguments $arguments + + $report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json -Depth 100 + $ruleLevels = @{} + foreach ($run in $report.runs) { + foreach ($rule in @($run.tool.driver.rules)) { + $defaultConfiguration = $rule.PSObject.Properties['defaultConfiguration'] + $configuredLevel = if ($defaultConfiguration) { + $defaultConfiguration.Value.PSObject.Properties['level'] + } else { + $null + } + $ruleLevels[$rule.id] = if ($configuredLevel) { [string]$configuredLevel.Value } else { 'warning' } + } + } + + $results = @($report.runs | ForEach-Object { @($_.results) }) + $approvedWarnings = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + [void]$approvedWarnings.Add('BA2027') # SourceLink is tracked explicitly in docs/autonomous-work-log.md. + $unexpectedFindings = [System.Collections.Generic.List[string]]::new() + + foreach ($result in $results) { + $resultLevelProperty = $result.PSObject.Properties['level'] + $effectiveLevel = if ($resultLevelProperty) { [string]$resultLevelProperty.Value } else { $ruleLevels[$result.ruleId] } + if ($effectiveLevel -notin @('error', 'warning')) { + continue + } + + $artifactUri = [string]$result.locations[0].physicalLocation.artifactLocation.uri + $binaryName = [System.IO.Path]::GetFileName(([uri]$artifactUri).LocalPath) + $findingKey = "${binaryName}:$($result.ruleId)" + if ($effectiveLevel -eq 'error' -or -not $approvedWarnings.Contains([string]$result.ruleId)) { + $unexpectedFindings.Add("${effectiveLevel}:$findingKey") + } + } + + if ($unexpectedFindings.Count -gt 0) { + throw "BinSkim found unapproved findings: $($unexpectedFindings -join ', '). Report: $reportPath" + } + Write-Host "[OK] BinSkim: $($results.Count) finding(s), no unapproved errors. Report: $reportPath" +} + +function Invoke-Audit { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [switch] $BuildFirst + ) + + if ($BuildFirst) { + Invoke-Build -Architectures $Architectures -Configuration 'Release' + } + foreach ($architecture in $Architectures) { + Write-Step "Auditing Release binaries for $architecture" + foreach ($moduleName in $script:ModuleNames) { + Assert-ReleaseBinary -Architecture $architecture -ModuleName $moduleName + } + Invoke-BinSkimAudit -Architecture $architecture + } +} + +function Invoke-Doctor { + $checks = [System.Collections.Generic.List[object]]::new() + $checks.Add([pscustomobject]@{ Tool = 'PowerShell'; Status = 'OK'; Detail = $PSVersionTable.PSVersion.ToString() }) + + foreach ($probe in @( + @{ Name = 'Visual Studio/MSVC'; Action = { $value = Resolve-VisualStudio; "$($value.InstallationPath), MSVC $($value.ToolsetVersion)" } }, + @{ Name = 'vcpkg'; Action = { (Resolve-Vcpkg).Root } }, + @{ Name = 'LLVM'; Action = { (Resolve-Llvm).Root } }, + @{ Name = 'Cppcheck'; Action = { Resolve-Cppcheck } }, + @{ Name = 'BinSkim'; Action = { Resolve-BinSkim } }, + @{ Name = 'UMDH'; Action = { (Resolve-WindowsDebuggingTool).Umdh } }, + @{ Name = 'PSScriptAnalyzer'; Action = { + $module = Get-Module -ListAvailable PSScriptAnalyzer | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $module) { throw 'PSScriptAnalyzer module was not found.' } + $module.Version.ToString() + } } + )) { + try { + $detail = & $probe.Action + if (-not $detail) { throw 'not found' } + $checks.Add([pscustomobject]@{ Tool = $probe.Name; Status = 'OK'; Detail = $detail }) + } catch { + $checks.Add([pscustomobject]@{ Tool = $probe.Name; Status = 'MISSING'; Detail = $_.Exception.Message }) + } + } + + Write-Host ($checks | Format-Table -AutoSize -Wrap | Out-String) + if ($checks.Status -contains 'MISSING') { + Write-Host 'Core build commands can still work when only analysis/coverage tools are missing.' -ForegroundColor Yellow + } +} + +function Invoke-Clean { + $resolvedArtifacts = [System.IO.Path]::GetFullPath($script:ArtifactsRoot) + $resolvedRepository = [System.IO.Path]::GetFullPath($script:RepositoryRoot) + if (-not $resolvedArtifacts.StartsWith($resolvedRepository, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean path outside the repository: $resolvedArtifacts" + } + if (Test-Path -LiteralPath $resolvedArtifacts) { + Remove-Item -Recurse -Force -LiteralPath $resolvedArtifacts + Write-Host "Removed $resolvedArtifacts" + } +} + +function Show-Help { + Write-Host @' +ObserverModules build entry point + + doctor inspect the complete toolchain + restore -Arch restore pinned static vcpkg dependencies + build -Arch build modules and tests + test -Arch build and run deterministic/corpus tests + source-checks -Arch clang-format, Cppcheck, PSScriptAnalyzer + compiler-analysis -Arch MSVC /analyze plus clang-tidy + test-coverage -Arch run tests and enforce llvm-cov source coverage + test-asan -Arch build dependencies/code and run tests with MSVC ASan + test-ubsan -Arch x64 build and run tests with clang-cl UBSan + test-leaks -Arch x64 run UMDH operation and DLL-lifecycle leak checks + fuzz -Arch x64 build and run one or all libFuzzer targets + audit-binaries -Arch build and inspect Release PE files with dumpbin + package -Arch module ZIPs plus one combined PDB ZIP + verify -Arch complete host-capable gate with explicit native deferrals + clean remove .artifacts + +Options: + -Config Debug|Release + -Corpus + -RestoreFlavor default|asan|all restore only; "all" prepares serial DAG dependency flavors + -SkipDependencyRestore DAG leaf only; dependencies must already be restored + -FuzzSeconds + -FuzzTarget pickle, renpy, rpgmaker, zanzarah, or all (default) + -LeakWarmup + -LeakIterations + -LeakWindows <3..10> + -LeakToleranceBytes defaults to zero; use only for a reviewed stack-specific exception + -CoverageThreshold <0..100> defaults to 100 for source lines and branches +'@ +} + +$architectures = Get-RequestedArchitecture -Requested $Arch +switch ($Command) { + 'help' { Show-Help } + 'doctor' { Invoke-Doctor } + 'restore' { + if ($SkipDependencyRestore) { + throw '-SkipDependencyRestore cannot be used with the restore command.' + } + Invoke-Restore -Architectures $architectures -Flavor $RestoreFlavor + } + 'build' { + Invoke-Restore -Architectures $architectures + Invoke-Build -Architectures $architectures -Configuration $Config + } + 'test' { + Invoke-Restore -Architectures $architectures + Invoke-Test -Architectures $architectures -Configuration $Config -CorpusPath $Corpus + } + 'source-checks' { + Invoke-Restore -Architectures $architectures + Invoke-Lint -Architectures $architectures + } + 'compiler-analysis' { + Invoke-Restore -Architectures $architectures + Invoke-CodeAnalysis -Architectures $architectures + } + 'test-coverage' { + Invoke-Restore -Architectures $architectures + Invoke-Coverage -Architectures $architectures -CorpusPath $Corpus -Threshold $CoverageThreshold + } + 'test-asan' { Invoke-ASan -Architectures $architectures } + 'test-ubsan' { Invoke-Ubsan -Architectures $architectures } + 'test-leaks' { + Invoke-LeakTest -Architectures $architectures -Warmup $LeakWarmup -Iterations $LeakIterations -Windows $LeakWindows -ToleranceBytes $LeakToleranceBytes + } + 'fuzz' { Invoke-Fuzz -Architectures $architectures -Seconds $FuzzSeconds -TargetName $FuzzTarget } + 'audit-binaries' { + Invoke-Restore -Architectures $architectures + Invoke-Audit -Architectures $architectures -BuildFirst + } + 'package' { Invoke-Package -Architectures $architectures } + 'verify' { + Invoke-Verify ` + -Architectures $architectures ` + -CorpusPath $Corpus ` + -RequiredCoverageThreshold $CoverageThreshold ` + -RequiredFuzzSeconds $FuzzSeconds ` + -RequiredLeakWarmup $LeakWarmup ` + -RequiredLeakIterations $LeakIterations ` + -RequiredLeakWindows $LeakWindows ` + -RequiredLeakToleranceBytes $LeakToleranceBytes + } + 'clean' { Invoke-Clean } +} diff --git a/build/dynamic_graph.py b/build/dynamic_graph.py new file mode 100644 index 0000000..bdbbf19 --- /dev/null +++ b/build/dynamic_graph.py @@ -0,0 +1,661 @@ +"""Deterministic schema-v2 records for fine-grained dynamic verification leaves.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from pathlib import PurePosixPath +import re + +from build.native_graph import ( + FUZZ_TARGET_NAMES as FUZZ_TARGETS, + build_project_node_name, + fuzz_build_node_name, +) + + +ARCHITECTURES = ("x86", "x64", "arm64") +MODULES = ("renpy", "rpgmaker", "zanzarah") +LEAK_MODES = ("operations", "lifecycle") +LEAK_SCENARIOS = ( + "small-success", + "malformed", + "cancellation", + "read-failure", + "write-failure", + "large-metadata", + "sparse-metadata", +) + +_NODE_FIELDS = { + "name", + "deps", + "run_after", + "argv", + "resources", + "inputs", + "writes", + "outputs", + "fingerprint", + "cacheable", +} +_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +class DynamicTopologyError(ValueError): + """Raised when normalized dynamic topology is unsafe or inconsistent.""" + + +def _claim(name: str, units: int = 1, _scope: str = "task") -> tuple[str, int]: + return name, units + + +def _node( + name: str, + kind: str, + *, + deps: Iterable[str], + argv: Sequence[str], + resources: Iterable[tuple[str, int]], + writes: Iterable[str], + outputs: Iterable[str], + cacheable: bool, + inputs: Iterable[str] = ("build/lib/dynamic-graph-leaves.ps1",), +) -> dict[str, object]: + resource_items = list(resources) + if len({name for name, _ in resource_items}) != len(resource_items): + raise DynamicTopologyError(f"duplicate resource claim in {name}") + return { + "name": name, + "deps": sorted(deps), + "run_after": [], + "argv": list(argv), + "resources": dict(sorted(resource_items)), + "inputs": sorted(inputs), + "writes": sorted(writes), + "outputs": sorted(outputs), + "fingerprint": ["contract=dynamic-microdag-v1", f"kind={kind}", f"node={name}"], + "cacheable": cacheable, + } + + +def _leaf_argv(leaf: str, *arguments: str) -> list[str]: + return [ + "pwsh", + "-NoLogo", + "-NoProfile", + "-File", + "build/lib/dynamic-graph-leaves.ps1", + "-Leaf", + leaf, + *arguments, + ] + + +def _release_build_dependency(architecture: str, project: str) -> str: + if architecture == "x64": + return build_project_node_name("Release", project) + return f"release-{architecture}-{project}" + + +def _add_fuzz(nodes: list[dict[str, object]], run_id: str) -> None: + for target in FUZZ_TARGETS: + persistent_base = f".artifacts/fuzz/x64/{target}" + run_base = f".artifacts/fuzz-runs/{run_id}/x64/{target}" + build_name = fuzz_build_node_name(target) + replay_name = f"fuzz-seed-replay-x64-{target}" + timed_name = f"fuzz-timed-x64-{target}" + nodes.append( + _node( + replay_name, + "fuzz-seed-replay", + deps=(build_name,), + argv=_leaf_argv( + "fuzz", + "-Phase", + "seed-replay", + "-Architecture", + "x64", + "-TargetName", + target, + "-RunId", + run_id, + ), + resources=(_claim("cpu", 1), _claim("memory-gib", 2), _claim(f"fuzz-writer-x64-{target}")), + writes=( + f"{run_base}/seed-replay", + f"{run_base}/seed-replay/result.json", + f"{run_base}/seed-replay/artifacts", + ), + outputs=(f"{run_base}/seed-replay/result.json",), + cacheable=False, + ) + ) + nodes.append( + _node( + timed_name, + "fuzz-timed", + deps=(replay_name,), + argv=_leaf_argv( + "fuzz", + "-Phase", + "timed", + "-Architecture", + "x64", + "-TargetName", + target, + "-RunId", + run_id, + ), + resources=( + _claim("cpu", 1), + _claim("memory-gib", 2), + _claim("fuzz-runtime", 1), + _claim(f"fuzz-writer-x64-{target}"), + ), + writes=( + f"{persistent_base}/corpus", + f"{run_base}/timed", + f"{run_base}/timed/result.json", + f"{run_base}/timed/artifacts", + ), + outputs=(f"{run_base}/timed/result.json",), + cacheable=False, + ) + ) + + +def _add_leaks(nodes: list[dict[str, object]], leak_windows: int) -> None: + audit_deps = tuple(f"audit-x64-{module}" for module in MODULES) + nodes.append( + _node( + "leak-setup", + "leak-setup", + deps=(*audit_deps, _release_build_dependency("x64", "leak-probe")), + argv=_leaf_argv("leak", "-Phase", "setup", "-Architecture", "x64"), + resources=(_claim("cpu", 1),), + writes=(".artifacts/reports/leaks/x64/release-binaries.json",), + outputs=(".artifacts/reports/leaks/x64/release-binaries.json",), + cacheable=False, + ) + ) + judges: list[str] = [] + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"leak-{mode}-{scenario}" + preflight_name = f"{stem}-preflight" + capture_name = f"{stem}-capture" + capture_root = f".artifacts/reports/leaks/x64/captures/{mode}/{scenario}" + preflight_output = f".artifacts/reports/leaks/x64/preflight/{mode}/{scenario}.json" + capture_output = f"{capture_root}/capture.json" + common = ( + "-Mode", + mode, + "-Scenario", + scenario, + "-Architecture", + "x64", + ) + nodes.append( + _node( + preflight_name, + "leak-preflight", + deps=("leak-setup",), + argv=_leaf_argv("leak", "-Phase", "preflight", *common), + resources=(_claim("cpu", 1), _claim("memory-gib", 1)), + writes=(preflight_output,), + outputs=(preflight_output,), + cacheable=False, + ) + ) + nodes.append( + _node( + capture_name, + "leak-capture", + deps=(preflight_name,), + argv=_leaf_argv("leak", "-Phase", "capture", *common), + resources=( + _claim("cpu", 1), + _claim("memory-gib", 1), + _claim("umdh-session", 1), + _claim("umdh-capture", 1, "invocation"), + ), + writes=(capture_root, capture_output), + outputs=(capture_output,), + cacheable=False, + ) + ) + diff_names: list[str] = [] + for index in range(1, leak_windows): + label = f"window-{index}" + diff_name = f"{stem}-diff-{label}" + output = f".artifacts/reports/leaks/x64/diffs/{mode}/{scenario}/{label}.json" + nodes.append( + _node( + diff_name, + "leak-diff", + deps=(capture_name,), + argv=_leaf_argv("leak", "-Phase", "diff", *common, "-Label", label), + resources=(_claim("cpu", 1), _claim("umdh-diff", 1)), + writes=(output,), + outputs=(output,), + cacheable=False, + ) + ) + diff_names.append(diff_name) + overall_name = f"{stem}-diff-overall" + overall_output = f".artifacts/reports/leaks/x64/diffs/{mode}/{scenario}/overall.json" + nodes.append( + _node( + overall_name, + "leak-diff", + deps=(capture_name,), + argv=_leaf_argv("leak", "-Phase", "diff", *common, "-Label", "overall"), + resources=(_claim("cpu", 1), _claim("umdh-diff", 1)), + writes=(overall_output,), + outputs=(overall_output,), + cacheable=False, + ) + ) + diff_names.append(overall_name) + judge_name = f"{stem}-judge" + judge_output = f".artifacts/reports/leaks/x64/summaries/{mode}/{scenario}.json" + nodes.append( + _node( + judge_name, + "leak-judge", + deps=diff_names, + argv=_leaf_argv("leak", "-Phase", "judge", *common), + resources=(_claim("cpu", 1),), + writes=(judge_output,), + outputs=(judge_output,), + cacheable=False, + ) + ) + judges.append(judge_name) + nodes.append( + _node( + "leaks-aggregate", + "leaks-aggregate", + deps=judges, + argv=_leaf_argv("leak", "-Phase", "aggregate", "-Architecture", "x64"), + resources=(_claim("cpu", 1),), + writes=(".artifacts/reports/leaks/x64/summary.json",), + outputs=(".artifacts/reports/leaks/x64/summary.json",), + cacheable=False, + ) + ) + + +def _add_audits(nodes: list[dict[str, object]], architectures: Sequence[str]) -> None: + for architecture in architectures: + for module in MODULES: + stem = f"audit-{architecture}-{module}" + release = _release_build_dependency(architecture, module) + dumpbin = f"{stem}-dumpbin" + binskim = f"{stem}-binskim" + dumpbin_output = f".artifacts/audit/{architecture}/{module}/dumpbin.json" + binskim_output = f".artifacts/audit/{architecture}/{module}/binskim.sarif" + summary_output = f".artifacts/audit/{architecture}/{module}/summary.json" + nodes.append( + _node( + dumpbin, + "audit-dumpbin", + deps=(release,), + argv=_leaf_argv( + "audit", "-Tool", "dumpbin", "-Architecture", architecture, "-ModuleName", module + ), + resources=(_claim("cpu", 1), _claim("dumpbin", 1)), + writes=(dumpbin_output,), + outputs=(dumpbin_output,), + cacheable=False, + ) + ) + nodes.append( + _node( + binskim, + "audit-binskim", + deps=(release,), + argv=_leaf_argv( + "audit", "-Tool", "binskim", "-Architecture", architecture, "-ModuleName", module + ), + resources=(_claim("cpu", 1), _claim("memory-gib", 1), _claim("binskim", 1)), + writes=(binskim_output,), + outputs=(binskim_output,), + cacheable=False, + ) + ) + nodes.append( + _node( + stem, + "audit-module", + deps=(dumpbin, binskim), + argv=_leaf_argv( + "audit", "-Tool", "aggregate", "-Architecture", architecture, "-ModuleName", module + ), + resources=(_claim("cpu", 1),), + writes=(summary_output,), + outputs=(summary_output,), + cacheable=False, + ) + ) + + +def _add_packages( + nodes: list[dict[str, object]], architectures: Sequence[str], host_architecture: str, run_id: str +) -> None: + root = f".artifacts/package-runs/{run_id}" + init_output = f"{root}/init.json" + nodes.append( + _node( + "package-init", + "package-init", + deps=(), + argv=_leaf_argv("package", "-Phase", "init", "-RunId", run_id), + resources=(_claim("cpu", 1), _claim("package-init", 1)), + writes=(init_output,), + outputs=(init_output,), + cacheable=False, + ) + ) + evidence_inputs: list[str] = [] + runnable = {"x86", "x64"} if host_architecture == "x64" else {host_architecture} + for architecture in architectures: + for module in MODULES: + stem = f"package-{architecture}-{module}" + create_name = f"{stem}-create" + content_name = f"{stem}-content" + runtime_name = f"{stem}-runtime" + archive = f"{root}/archives/{architecture}/{module}.zip" + content_fragment = f"{root}/evidence/content/{architecture}/{module}.json" + runtime_fragment = f"{root}/evidence/runtime/{architecture}/{module}.json" + nodes.append( + _node( + create_name, + "package-create", + deps=("package-init", f"audit-{architecture}-{module}"), + argv=_leaf_argv( + "package", + "-Phase", + "create-module", + "-RunId", + run_id, + "-Architecture", + architecture, + "-ModuleName", + module, + ), + resources=(_claim("cpu", 1), _claim("archive-io", 1)), + writes=(f"{root}/stage/{architecture}/{module}", archive), + outputs=(archive,), + cacheable=False, + ) + ) + nodes.append( + _node( + content_name, + "package-content-smoke", + deps=(create_name,), + argv=_leaf_argv( + "package", + "-Phase", + "content-module", + "-RunId", + run_id, + "-Architecture", + architecture, + "-ModuleName", + module, + ), + resources=(_claim("cpu", 1), _claim("archive-io", 1)), + writes=(f"{root}/extracted/{architecture}/{module}", content_fragment), + outputs=(content_fragment,), + cacheable=False, + ) + ) + runtime_kind = "package-runtime-smoke" if architecture in runnable else "package-runtime-deferred" + runtime_deps = [content_name] + if runtime_kind == "package-runtime-smoke": + runtime_deps.append(_release_build_dependency(architecture, "tests")) + nodes.append( + _node( + runtime_name, + runtime_kind, + deps=runtime_deps, + argv=_leaf_argv( + "package", + "-Phase", + "runtime-module" if runtime_kind == "package-runtime-smoke" else "defer-runtime", + "-RunId", + run_id, + "-Architecture", + architecture, + "-ModuleName", + module, + ), + resources=(_claim("cpu", 1), _claim("runtime-smoke", 1)), + writes=(f"{root}/reports/{architecture}/{module}.xml", runtime_fragment), + outputs=(runtime_fragment,), + cacheable=False, + ) + ) + evidence_inputs.append(runtime_name) + symbols_name = f"symbols-{architecture}-create" + symbols_content_name = f"symbols-{architecture}-content" + symbols_archive = f"{root}/archives/{architecture}/symbols.zip" + symbols_fragment = f"{root}/evidence/symbols/{architecture}.json" + nodes.append( + _node( + symbols_name, + "symbols-create", + deps=( + "package-init", + *(_release_build_dependency(architecture, module) for module in MODULES), + ), + argv=_leaf_argv( + "package", "-Phase", "create-symbols", "-RunId", run_id, "-Architecture", architecture + ), + resources=(_claim("cpu", 1), _claim("archive-io", 1)), + writes=(f"{root}/stage/{architecture}/symbols", symbols_archive), + outputs=(symbols_archive,), + cacheable=False, + ) + ) + nodes.append( + _node( + symbols_content_name, + "symbols-content-smoke", + deps=(symbols_name,), + argv=_leaf_argv( + "package", "-Phase", "content-symbols", "-RunId", run_id, "-Architecture", architecture + ), + resources=(_claim("cpu", 1), _claim("archive-io", 1)), + writes=(f"{root}/extracted/{architecture}/symbols", symbols_fragment), + outputs=(symbols_fragment,), + cacheable=False, + ) + ) + evidence_inputs.append(symbols_content_name) + evidence = f"{root}/package-smoke-evidence.json" + nodes.append( + _node( + "package-evidence", + "package-evidence", + deps=evidence_inputs, + argv=_leaf_argv("package", "-Phase", "aggregate", "-RunId", run_id), + resources=(_claim("cpu", 1),), + writes=(evidence,), + outputs=(evidence,), + cacheable=False, + ) + ) + + +def build_dynamic_topology( + *, + architectures: Sequence[str] = ARCHITECTURES, + host_architecture: str = "x64", + leak_windows: int = 3, + run_id: str, +) -> dict[str, object]: + """Build deterministic records; execution is deliberately owned by leaf adapters.""" + + selected_architectures = tuple(architectures) + if ( + not selected_architectures + or len(set(selected_architectures)) != len(selected_architectures) + or any(architecture not in ARCHITECTURES for architecture in selected_architectures) + ): + raise DynamicTopologyError("architectures must be a non-empty unique subset of x86, x64, and arm64") + if host_architecture not in ARCHITECTURES: + raise DynamicTopologyError("unsupported host architecture") + if isinstance(leak_windows, bool) or not 3 <= leak_windows <= 10: + raise DynamicTopologyError("leak_windows must be from 3 to 10") + if not _RUN_ID.fullmatch(run_id): + raise DynamicTopologyError("run_id must be a safe 1-128 character path component") + + nodes: list[dict[str, object]] = [] + _add_fuzz(nodes, run_id) + _add_audits(nodes, selected_architectures) + _add_leaks(nodes, leak_windows) + _add_packages(nodes, selected_architectures, host_architecture, run_id) + external_nodes = { + _release_build_dependency("x64", "leak-probe"), + *(fuzz_build_node_name(target) for target in FUZZ_TARGETS), + *( + _release_build_dependency(architecture, module) + for architecture in selected_architectures + for module in MODULES + ), + *( + _release_build_dependency(architecture, "tests") + for architecture in selected_architectures + if architecture in ({"x86", "x64"} if host_architecture == "x64" else {host_architecture}) + ), + } + result = { + "schema": 2, + "external_nodes": sorted(external_nodes), + "nodes": sorted(nodes, key=lambda node: str(node["name"])), + } + validate_dynamic_topology(result) + return result + + +def _safe_relative_path(value: object) -> bool: + if not isinstance(value, str) or not value: + return False + path = PurePosixPath(value) + return not path.is_absolute() and ".." not in path.parts and "\\" not in value + + +def _paths_overlap(first: str, second: str) -> bool: + first_parts = tuple(part.casefold() for part in PurePosixPath(first).parts) + second_parts = tuple(part.casefold() for part in PurePosixPath(second).parts) + common = min(len(first_parts), len(second_parts)) + return first_parts[:common] == second_parts[:common] + + +def validate_dynamic_topology(topology: dict[str, object]) -> None: + """Reject unsafe paths, duplicate ownership, unknown dependencies, and cycles.""" + + if topology.get("schema") != 2: + raise DynamicTopologyError("dynamic topology schema must be 2") + nodes = topology.get("nodes") + external_nodes = topology.get("external_nodes") + if not isinstance(nodes, list) or not isinstance(external_nodes, list): + raise DynamicTopologyError("nodes and external_nodes must be lists") + if ( + not all(isinstance(name, str) and name for name in external_nodes) + or len(set(external_nodes)) != len(external_nodes) + ): + raise DynamicTopologyError("external_nodes must contain unique non-empty names") + names: set[str] = set() + write_owners: dict[str, str] = {} + output_owners: dict[str, str] = {} + for node in nodes: + if not isinstance(node, dict) or set(node) != _NODE_FIELDS: + raise DynamicTopologyError("every node must use the normalized schema-v2 fields") + name = node["name"] + if not isinstance(name, str) or not name or name in names: + raise DynamicTopologyError(f"duplicate or invalid node name: {name!r}") + names.add(name) + if not isinstance(node["cacheable"], bool): + raise DynamicTopologyError(f"cacheable must be boolean: {name}") + resources = node["resources"] + if not isinstance(resources, dict) or not resources: + raise DynamicTopologyError(f"node has no resource claims: {name}") + for resource_name, units in resources.items(): + if ( + not isinstance(resource_name, str) + or not resource_name + or isinstance(units, bool) + or not isinstance(units, int) + or units < 1 + ): + raise DynamicTopologyError(f"invalid resource claim: {name}") + for field, owners in (("writes", write_owners), ("outputs", output_owners)): + values = node[field] + if not isinstance(values, list): + raise DynamicTopologyError(f"{field} must be a list: {name}") + for value in values: + if not _safe_relative_path(value): + raise DynamicTopologyError(f"unsafe {field} path in {name}: {value!r}") + if value in owners: + raise DynamicTopologyError(f"{field} path has multiple owners: {value}") + if field == "writes": + for owned_path, owner in write_owners.items(): + if owner != name and _paths_overlap(owned_path, value): + raise DynamicTopologyError( + f"write paths overlap between {owner} and {name}: {owned_path}, {value}" + ) + owners[value] = name + if any( + not any(_paths_overlap(write, output) for write in node["writes"]) + for output in node["outputs"] + ): + raise DynamicTopologyError(f"every output must be below a declared write root: {name}") + external_names = set(external_nodes) + if names & external_names: + raise DynamicTopologyError("external_nodes must not collide with generated node names") + known = names | external_names + children: dict[str, list[str]] = {name: [] for name in names} + indegree = {name: 0 for name in names} + for node in nodes: + name = str(node["name"]) + deps = node["deps"] + run_after = node["run_after"] + argv = node["argv"] + inputs = node["inputs"] + fingerprint = node["fingerprint"] + if ( + not isinstance(deps, list) + or not isinstance(run_after, list) + or set(deps) & set(run_after) + or not isinstance(inputs, list) + or not isinstance(fingerprint, list) + or not isinstance(argv, list) + or not argv + or not all( + isinstance(argument, str) and argument for argument in argv + ) + or not all(isinstance(value, str) for value in (*inputs, *fingerprint)) + ): + raise DynamicTopologyError(f"invalid deps or argv: {name}") + for dependency in (*deps, *run_after): + if dependency not in known: + raise DynamicTopologyError(f"unknown dependency {dependency!r} in {name}") + if dependency in names: + children[dependency].append(name) + indegree[name] += 1 + ready = sorted(name for name, count in indegree.items() if count == 0) + visited = 0 + while ready: + current = ready.pop(0) + visited += 1 + for child in sorted(children[current]): + indegree[child] -= 1 + if indegree[child] == 0: + ready.append(child) + ready.sort() + if visited != len(names): + raise DynamicTopologyError("dynamic topology contains a dependency cycle") diff --git a/build/graph_driver.py b/build/graph_driver.py new file mode 100644 index 0000000..a4f0469 --- /dev/null +++ b/build/graph_driver.py @@ -0,0 +1,1063 @@ +#!/usr/bin/env python3 +"""Stdlib-only DAG runner for local Observer build and verification work.""" + +from __future__ import annotations + +import argparse +from collections import deque +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from dataclasses import dataclass, replace +import glob +import hashlib +import heapq +from itertools import product +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import threading +import time +from typing import Callable, Mapping, Sequence, TextIO +import uuid + + +SCHEMA = 2 +NAME = re.compile(r"^[A-Za-z0-9_.-]+$") +VARIABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +VARIABLE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") +GOOD = frozenset({"succeeded", "cached"}) +BAD = frozenset({"failed", "blocked", "cancelled"}) +FAILURE_POLICIES = frozenset({"continue", "fail-fast"}) + + +class GraphValidationError(ValueError): + pass + + +class CycleError(GraphValidationError): + pass + + +@dataclass(frozen=True) +class Node: + name: str + deps: tuple[str, ...] + run_after: tuple[str, ...] + resources: tuple[tuple[str, int], ...] + argv: tuple[str, ...] + inputs: tuple[str, ...] + writes: tuple[str, ...] + outputs: tuple[str, ...] + fingerprint_tokens: tuple[str, ...] + cacheable: bool + + @property + def prerequisites(self) -> tuple[str, ...]: + return self.deps + self.run_after + + +@dataclass(frozen=True) +class PlannedNode: + node: Node + fingerprint: str + + +@dataclass(frozen=True) +class NodeResult: + name: str + status: str + return_code: int | None + duration_seconds: float + log_path: Path + fingerprint: str + detail: str = "" + output_manifest: tuple[Mapping[str, str], ...] = () + + +@dataclass(frozen=True) +class RunSummary: + plan: tuple[PlannedNode, ...] + results: Mapping[str, NodeResult] + duration_seconds: float + + @property + def succeeded(self) -> bool: + return all(result.status in GOOD for result in self.results.values()) + + +def _strings(value: object, label: str) -> tuple[str, ...]: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise GraphValidationError(f"{label} must be an array of strings") + return tuple(value) + + +def _name(value: object, label: str) -> str: + if not isinstance(value, str) or not NAME.fullmatch(value): + raise GraphValidationError(f"invalid {label}: {value!r}") + return value + + +def _variable_name(value: object, label: str) -> str: + if not isinstance(value, str) or not VARIABLE_NAME.fullmatch(value): + raise GraphValidationError(f"invalid {label}: {value!r}") + return value + + +def _positive_integer(value: object, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise GraphValidationError(f"{label} must be a positive integer") + return value + + +def _relative(value: str, label: str, *, patterns: bool) -> None: + path = PurePosixPath(value) + if ( + not value + or "\\" in value + or path.is_absolute() + or ".." in path.parts + or path.parts[0].endswith(":") + or (not patterns and glob.has_magic(value)) + ): + raise GraphValidationError(f"unsafe {label}: {value!r}") + + +def _inside(root: Path, path: Path) -> bool: + try: + return os.path.commonpath((os.path.normcase(root), os.path.normcase(path))) == os.path.normcase(root) + except ValueError: + return False + + +def _path(root: Path, relative: str) -> Path: + result = root.joinpath(*PurePosixPath(relative).parts).resolve() + if not _inside(root, result): + raise GraphValidationError(f"path resolves outside the workspace: {relative!r}") + return result + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.lstat(), "st_file_attributes", 0) + except OSError: + return False + return path.is_symlink() or bool(attributes & 0x400) + + +class _WorkspaceRunLock: + """One non-blocking OS lock serializes graph runs that share a workspace.""" + + def __init__(self, workspace: Path): + self.workspace = Path(workspace).resolve() + self.handle = None + + def __enter__(self): + lock_directory = _path(self.workspace, ".artifacts/graph") + lock_directory.mkdir(parents=True, exist_ok=True) + lock_path = lock_directory / ".workspace-run.lock" + if _is_reparse_point(lock_path): + raise GraphValidationError(f"workspace run lock is a reparse point: {lock_path}") + handle = lock_path.open("a+b", buffering=0) + if lock_path.stat().st_size == 0: + handle.write(b"\0") + handle.seek(0) + try: + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + handle.close() + raise GraphValidationError( + f"a graph run is already running in workspace {self.workspace}" + ) from error + self.handle = handle + return self + + def __exit__(self, exception_type, _exception, _traceback): + if self.handle is None: + return False + unlock_error = None + try: + self.handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + except OSError as error: + unlock_error = error + finally: + self.handle.close() + self.handle = None + if unlock_error is not None and exception_type is None: + raise GraphValidationError("failed to release the workspace graph run lock") from unlock_error + return False + + +class Graph: + def __init__(self, name, workspace, resources, nodes, targets, failure_policy): + self.name = name + self.workspace = workspace + self.resources = resources + self.nodes = nodes + self.targets = targets + self.failure_policy = failure_policy + + @classmethod + def from_mapping(cls, name: str, mapping: object, workspace: Path) -> "Graph": + graph_name = _name(name, "graph name") + root = Path(workspace).resolve() + if not isinstance(mapping, dict) or not root.is_dir(): + raise GraphValidationError("graph mapping and existing workspace are required") + if "pools" in mapping: + raise GraphValidationError("legacy pools are not accepted by schema 2") + allowed_graph_keys = {"failure_policy", "resources", "targets", "nodes"} + unexpected_graph_keys = set(mapping) - allowed_graph_keys + if unexpected_graph_keys: + raise GraphValidationError(f"unknown graph fields: {sorted(unexpected_graph_keys)}") + + raw_resources = mapping.get("resources") + if not isinstance(raw_resources, dict) or not raw_resources: + raise GraphValidationError("named resources are required") + resources: dict[str, int] = {} + for key, capacity in raw_resources.items(): + resource = _name(key, "resource name") + resources[resource] = _positive_integer(capacity, f"capacity for {resource}") + + failure_policy = mapping.get("failure_policy", "continue") + if failure_policy not in FAILURE_POLICIES: + raise GraphValidationError("failure_policy must be continue or fail-fast") + + raw_nodes = mapping.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise GraphValidationError("nodes are required") + nodes: dict[str, Node] = {} + allowed_node_keys = { + "name", + "deps", + "run_after", + "resources", + "argv", + "inputs", + "writes", + "outputs", + "fingerprint", + "cacheable", + } + for raw in raw_nodes: + if not isinstance(raw, dict): + raise GraphValidationError("every node must be an object") + if "pool" in raw: + raise GraphValidationError("legacy pool is not accepted by schema 2") + unexpected = set(raw) - allowed_node_keys + if unexpected: + raise GraphValidationError(f"unknown node fields: {sorted(unexpected)}") + current = _name(raw.get("name"), "node name") + if current in nodes: + raise GraphValidationError(f"duplicate node: {current}") + + deps = _strings(raw.get("deps"), f"deps for {current}") + run_after = _strings(raw.get("run_after"), f"run_after for {current}") + argv = _strings(raw.get("argv"), f"argv for {current}") + inputs = _strings(raw.get("inputs"), f"inputs for {current}") + writes = _strings(raw.get("writes"), f"writes for {current}") + outputs = _strings(raw.get("outputs"), f"outputs for {current}") + tokens = _strings(raw.get("fingerprint"), f"fingerprint for {current}") + cacheable = raw.get("cacheable") + raw_demands = raw.get("resources") + if not argv or any(not arg for arg in argv): + raise GraphValidationError(f"non-empty argv is required for {current}") + if len(deps) != len(set(deps)) or len(run_after) != len(set(run_after)): + raise GraphValidationError(f"duplicate prerequisite for {current}") + overlap = set(deps) & set(run_after) + if overlap: + raise GraphValidationError( + f"prerequisite cannot be both deps and run_after for {current}: {sorted(overlap)}" + ) + if not isinstance(raw_demands, dict) or not raw_demands: + raise GraphValidationError(f"resources for {current} must be a non-empty object") + demands: dict[str, int] = {} + for key, raw_demand in raw_demands.items(): + resource = _name(key, f"resource for {current}") + if resource not in resources: + raise GraphValidationError(f"unknown resource {resource!r} for {current}") + demand = _positive_integer(raw_demand, f"demand for {current}/{resource}") + if demand > resources[resource]: + raise GraphValidationError( + f"resource demand for {current}/{resource} exceeds capacity {resources[resource]}" + ) + demands[resource] = demand + if not isinstance(cacheable, bool): + raise GraphValidationError(f"cacheable must be boolean for {current}") + if cacheable and not outputs: + raise GraphValidationError(f"cacheable node {current!r} requires explicit outputs") + if len(writes) != len(set(writes)) or len(outputs) != len(set(outputs)): + raise GraphValidationError(f"duplicate write or output path for {current}") + for item in inputs: + _relative(item, f"input for {current}", patterns=True) + for item in writes: + _relative(item, f"write for {current}", patterns=False) + _path(root, item) + for item in outputs: + _relative(item, f"output for {current}", patterns=False) + output_path = _path(root, item) + if not any(_inside(_path(root, write), output_path) for write in writes): + raise GraphValidationError( + f"output for {current} is not below a declared write root: {item!r}" + ) + nodes[current] = Node( + current, + tuple(sorted(deps)), + tuple(sorted(run_after)), + tuple(sorted(demands.items())), + argv, + tuple(sorted(inputs)), + tuple(sorted(writes)), + tuple(sorted(outputs)), + tuple(sorted(tokens)), + cacheable, + ) + + for current in nodes.values(): + unknown = set(current.prerequisites) - nodes.keys() + if unknown: + raise GraphValidationError(f"unknown prerequisites for {current.name}: {sorted(unknown)}") + targets = _strings(mapping.get("targets"), "targets") + if not targets or len(targets) != len(set(targets)) or set(targets) - nodes.keys(): + raise GraphValidationError("targets must be unique, non-empty, and known") + return cls(graph_name, root, resources, nodes, tuple(sorted(targets)), failure_policy) + + def _closure(self, targets: Sequence[str]) -> set[str]: + if set(targets) - self.nodes.keys(): + raise GraphValidationError("unknown target") + result, pending = set(), list(targets) + while pending: + current = pending.pop() + if current not in result: + result.add(current) + pending.extend(self.nodes[current].prerequisites) + return result + + def _cycle(self, remaining: set[str]) -> list[str]: + state: dict[str, int] = {} + stack: list[str] = [] + positions: dict[str, int] = {} + + def visit(current): + state[current], positions[current] = 1, len(stack) + stack.append(current) + for dependency in self.nodes[current].prerequisites: + if dependency not in remaining: + continue + if not state.get(dependency): + found = visit(dependency) + if found: + return found + elif state[dependency] == 1: + return stack[positions[dependency] :] + [dependency] + stack.pop() + positions.pop(current) + state[current] = 2 + return None + + for current in sorted(remaining): + if not state.get(current) and (found := visit(current)): + return found + return sorted(remaining) + + def _order(self, targets: Sequence[str]) -> list[str]: + closure = self._closure(targets) + indegree = { + name: sum(dependency in closure for dependency in self.nodes[name].prerequisites) + for name in closure + } + children = {name: [] for name in closure} + for name in closure: + for dependency in self.nodes[name].prerequisites: + if dependency in closure: + children[dependency].append(name) + ready = [name for name, count in indegree.items() if not count] + heapq.heapify(ready) + ordered: list[str] = [] + while ready: + current = heapq.heappop(ready) + ordered.append(current) + for child in sorted(children[current]): + indegree[child] -= 1 + if not indegree[child]: + heapq.heappush(ready, child) + if len(ordered) != len(closure): + cycle = self._cycle(closure - set(ordered)) + raise CycleError(f"cycle detected: {' -> '.join(cycle)}") + return ordered + + def _validate_write_conflicts(self, ordered: Sequence[str]) -> None: + ancestors: dict[str, set[str]] = {} + for name in ordered: + current: set[str] = set() + for dependency in self.nodes[name].prerequisites: + if dependency in ancestors: + current.add(dependency) + current.update(ancestors[dependency]) + ancestors[name] = current + + for left_index, left_name in enumerate(ordered): + left = self.nodes[left_name] + left_resources = dict(left.resources) + for right_name in ordered[left_index + 1 :]: + right = self.nodes[right_name] + if left_name in ancestors[right_name] or right_name in ancestors[left_name]: + continue + right_resources = dict(right.resources) + has_lock = any( + self.resources[resource] == 1 and resource in right_resources + for resource in left_resources + ) + if has_lock: + continue + for left_write in left.writes: + left_path = _path(self.workspace, left_write) + for right_write in right.writes: + right_path = _path(self.workspace, right_write) + if _inside(left_path, right_path) or _inside(right_path, left_path): + raise GraphValidationError( + f"overlapping writes require dependency order or a shared capacity-one resource: " + f"{left_name}:{left_write!r}, {right_name}:{right_write!r}" + ) + + def _input_records( + self, + node: Node, + digests: dict[Path, str], + ) -> list[tuple[str, str]]: + records: dict[str, str] = {} + missing: list[tuple[str, str]] = [] + for pattern in node.inputs: + matches = sorted(path for path in self.workspace.glob(pattern) if path.is_file()) + if not matches: + missing.append((pattern, "missing")) + for match in matches: + resolved = match.resolve() + if not _inside(self.workspace, resolved): + raise GraphValidationError(f"input escapes workspace: {match}") + if resolved not in digests: + digests[resolved] = _sha256_file(resolved) + records[match.relative_to(self.workspace).as_posix()] = digests[resolved] + return sorted(records.items()) + missing + + def _planned_node( + self, + name: str, + fingerprints: Mapping[str, str], + digests: dict[Path, str], + ) -> PlannedNode: + node = self.nodes[name] + payload = { + "schema": SCHEMA, + "name": name, + "argv": node.argv, + "resources": dict(node.resources), + "cacheable": node.cacheable, + "writes": node.writes, + "outputs": node.outputs, + "tokens": node.fingerprint_tokens, + "deps": {dep: fingerprints[dep] for dep in node.deps}, + "run_after": {dep: fingerprints[dep] for dep in node.run_after}, + "inputs": self._input_records(node, digests), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return PlannedNode(node, hashlib.sha256(encoded).hexdigest()) + + def execution_order(self, targets: Sequence[str] | None = None) -> tuple[str, ...]: + ordered = self._order(targets or self.targets) + self._validate_write_conflicts(ordered) + return tuple(ordered) + + def plan(self, targets: Sequence[str] | None = None) -> tuple[PlannedNode, ...]: + fingerprints: dict[str, str] = {} + digests: dict[Path, str] = {} + result: list[PlannedNode] = [] + for name in self.execution_order(targets): + item = self._planned_node(name, fingerprints, digests) + fingerprints[name] = item.fingerprint + result.append(item) + return tuple(result) + + +Executor = Callable[[Node, Path, TextIO, threading.Event], int] + + +def execute_subprocess( + node: Node, + workspace: Path, + stream: TextIO, + _cancellation: threading.Event, +) -> int: + return subprocess.run( + list(node.argv), + cwd=workspace, + stdin=subprocess.DEVNULL, + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + shell=False, + ).returncode + + +class GraphRunner: + def __init__( + self, + graph: Graph, + logs_dir: Path, + state_dir: Path, + *, + max_workers: int | None = None, + executor: Executor = execute_subprocess, + ): + self.graph, self.executor = graph, executor + self.logs_root, self.state_dir = Path(logs_dir).resolve(), Path(state_dir).resolve() + if not _inside(graph.workspace, self.logs_root) or not _inside(graph.workspace, self.state_dir): + raise GraphValidationError("log and state paths must stay inside the workspace") + if max_workers is None: + self.max_workers = sum(graph.resources.values()) + else: + self.max_workers = max_workers + if isinstance(self.max_workers, bool) or not isinstance(self.max_workers, int) or self.max_workers < 1: + raise GraphValidationError("max_workers must be positive") + + def _new_logs_dir(self) -> Path: + self.logs_root.mkdir(parents=True, exist_ok=True) + for _ in range(10): + run_id = f"run-{time.time_ns()}-{os.getpid()}-{uuid.uuid4().hex}" + target = self.logs_root / run_id + try: + target.mkdir() + except FileExistsError: + continue + return target + raise RuntimeError("could not allocate a unique graph log directory") + + @staticmethod + def _log(logs_dir: Path, node: Node) -> Path: + return logs_dir / f"{node.name}.log" + + def _state(self, node: Node) -> Path: + return self.state_dir / f"{node.name}.json" + + def _tree_digest(self, root: Path) -> str: + digest = hashlib.sha256() + for current, directories, files in os.walk(root, topdown=True, followlinks=False): + current_path = Path(current) + directories.sort() + files.sort() + for name in directories: + entry = current_path / name + if _is_reparse_point(entry): + raise GraphValidationError(f"output tree contains a reparse point: {entry}") + relative = entry.relative_to(root).as_posix() + digest.update(f"D\0{relative}\0".encode()) + for name in files: + entry = current_path / name + if _is_reparse_point(entry): + raise GraphValidationError(f"output tree contains a reparse point: {entry}") + relative = entry.relative_to(root).as_posix() + digest.update(f"F\0{relative}\0{_sha256_file(entry)}\0".encode()) + return digest.hexdigest() + + def _output_manifest(self, node: Node) -> list[dict[str, str]]: + records: list[dict[str, str]] = [] + for relative in node.outputs: + raw_path = self.graph.workspace.joinpath(*PurePosixPath(relative).parts) + if _is_reparse_point(raw_path): + raise GraphValidationError(f"output is a reparse point: {relative!r}") + path = _path(self.graph.workspace, relative) + if path.is_file(): + records.append({"kind": "file", "path": relative, "sha256": _sha256_file(path)}) + elif path.is_dir(): + records.append({"kind": "directory", "path": relative, "sha256": self._tree_digest(path)}) + else: + raise FileNotFoundError(relative) + return records + + def _cached(self, item: PlannedNode) -> bool: + if not item.node.cacheable: + return False + try: + state = json.loads(self._state(item.node).read_text(encoding="utf-8")) + manifest = self._output_manifest(item.node) + except (OSError, json.JSONDecodeError, GraphValidationError): + return False + return state == { + "fingerprint": item.fingerprint, + "outputs": manifest, + "schema": SCHEMA, + } + + def _invalidate(self, item: PlannedNode) -> None: + if item.node.cacheable: + self._state(item.node).unlink(missing_ok=True) + + def _save( + self, + item: PlannedNode, + manifest: Sequence[Mapping[str, str]], + ) -> None: + target = self._state(item.node) + temporary = target.with_name(f".{target.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_text( + json.dumps( + { + "fingerprint": item.fingerprint, + "outputs": list(manifest), + "schema": SCHEMA, + }, + sort_keys=True, + ), + encoding="utf-8", + ) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + def _record(self, item: PlannedNode, logs_dir: Path, status: str, detail: str = "") -> NodeResult: + log = self._log(logs_dir, item.node) + log.write_text(f"[{status}] {detail}\n", encoding="utf-8") + return NodeResult(item.node.name, status, None, 0.0, log, item.fingerprint, detail) + + def _execute( + self, + item: PlannedNode, + logs_dir: Path, + cancellation: threading.Event, + ) -> NodeResult: + started, node, log = time.perf_counter(), item.node, self._log(logs_dir, item.node) + code: int | None = None + detail = "" + manifest: tuple[Mapping[str, str], ...] = () + try: + with log.open("w", encoding="utf-8") as stream: + stream.write(f"fingerprint={item.fingerprint}\nargv={json.dumps(node.argv)}\n") + stream.flush() + code = self.executor(node, self.graph.workspace, stream, cancellation) + if isinstance(code, bool) or not isinstance(code, int): + raise TypeError("executor must return an integer") + except Exception as error: + detail = f"executor error: {type(error).__name__}: {error}" + with log.open("a", encoding="utf-8") as stream: + stream.write(detail + "\n") + if code == 0 and not detail and node.cacheable: + try: + manifest = tuple(self._output_manifest(node)) + except Exception as error: + detail = f"output manifest error: {type(error).__name__}: {error}" + with log.open("a", encoding="utf-8") as stream: + stream.write(detail + "\n") + if cancellation.is_set(): + status = "cancelled" + detail = detail or "cancelled after fail-fast failure" + else: + status = "succeeded" if code == 0 and not detail else "failed" + return NodeResult( + node.name, + status, + code, + time.perf_counter() - started, + log, + item.fingerprint, + detail, + manifest, + ) + + def run(self, targets: Sequence[str] | None = None) -> RunSummary: + with _WorkspaceRunLock(self.graph.workspace): + return self._run(targets) + + def _run(self, targets: Sequence[str] | None = None) -> RunSummary: + started = time.perf_counter() + ordered = self.graph.execution_order(targets) + order_index = {name: index for index, name in enumerate(ordered)} + items: dict[str, PlannedNode] = {} + fingerprints: dict[str, str] = {} + digests: dict[Path, str] = {} + results: dict[str, NodeResult] = {} + used = {resource: 0 for resource in self.graph.resources} + running: dict[object, PlannedNode] = {} + checking: dict[object, PlannedNode] = {} + remaining = { + name: len(self.graph.nodes[name].prerequisites) + for name in ordered + } + children = {name: [] for name in ordered} + for name in ordered: + for dependency in self.graph.nodes[name].prerequisites: + children[dependency].append(name) + for dependents in children.values(): + dependents.sort(key=order_index.__getitem__) + ready = deque((name, False) for name in ordered if remaining[name] == 0) + logs_dir = self._new_logs_dir() + self.state_dir.mkdir(parents=True, exist_ok=True) + aborting = False + cancellation = threading.Event() + + def ensure_item(name: str) -> PlannedNode: + if name not in items: + item = self.graph._planned_node(name, fingerprints, digests) + items[name] = item + fingerprints[name] = item.fingerprint + return items[name] + + def placeholder(name: str) -> PlannedNode: + return items.get(name, PlannedNode(self.graph.nodes[name], "")) + + def finish(name: str, result: NodeResult) -> None: + results[name] = result + for child in children[name]: + remaining[child] -= 1 + if remaining[child] == 0 and child not in results: + ready.append((child, False)) + + def fits(item: PlannedNode) -> bool: + if len(running) + len(checking) >= self.max_workers: + return False + return all( + used[resource] + demand <= self.graph.resources[resource] + for resource, demand in item.node.resources + ) + + def reserve(item: PlannedNode, direction: int) -> None: + for resource, demand in item.node.resources: + used[resource] += direction * demand + + def invalidate_digests(node: Node) -> None: + roots = tuple(_path(self.graph.workspace, write) for write in node.writes) + for cached_path in tuple(digests): + if any(_inside(root, cached_path) for root in roots): + digests.pop(cached_path) + + def state_failure(item: PlannedNode, result: NodeResult, error: Exception) -> NodeResult: + detail = f"cache state error: {type(error).__name__}: {error}" + try: + self._invalidate(item) + except OSError as invalidation_error: + detail += f"; state invalidation error: {invalidation_error}" + with result.log_path.open("a", encoding="utf-8") as stream: + stream.write(detail + "\n") + return replace(result, status="failed", detail=detail, output_manifest=()) + + with ( + ThreadPoolExecutor(max_workers=self.max_workers) as workers, + ThreadPoolExecutor(max_workers=self.max_workers) as cache_workers, + ): + while ready or running or checking: + dispatch_progress = False + for _ in range(len(ready)): + name, cache_checked = ready.popleft() + item = ensure_item(name) + if aborting: + finish(name, self._record(item, logs_dir, "cancelled", "fail-fast policy")) + dispatch_progress = True + continue + failed = sorted( + dependency + for dependency in item.node.deps + if results[dependency].status in BAD + ) + if failed: + finish( + name, + self._record(item, logs_dir, "blocked", f"dependency failure: {', '.join(failed)}"), + ) + dispatch_progress = True + continue + prerequisites_cached = all( + results[dependency].status == "cached" + for dependency in item.node.prerequisites + ) + if prerequisites_cached and item.node.cacheable and not cache_checked: + if fits(item): + reserve(item, 1) + checking[cache_workers.submit(self._cached, item)] = item + dispatch_progress = True + else: + ready.append((name, cache_checked)) + continue + if fits(item): + try: + self._invalidate(item) + except OSError as error: + finish( + name, + self._record( + item, + logs_dir, + "failed", + f"cache state invalidation error: {type(error).__name__}: {error}", + ), + ) + dispatch_progress = True + continue + reserve(item, 1) + future = workers.submit(self._execute, item, logs_dir, cancellation) + running[future] = item + dispatch_progress = True + else: + ready.append((name, cache_checked)) + + if running or checking: + done, _ = wait(tuple(running) + tuple(checking), return_when=FIRST_COMPLETED) + for future in sorted( + (current for current in done if current in checking), + key=lambda current: order_index[checking[current].node.name], + ): + item = checking.pop(future) + reserve(item, -1) + if item.node.name in results: + continue + if future.result(): + finish( + item.node.name, + self._record(item, logs_dir, "cached", "fingerprint and outputs match"), + ) + else: + ready.append((item.node.name, True)) + failed_now = False + for future in sorted( + (current for current in done if current in running), + key=lambda current: order_index[running[current].node.name], + ): + item = running.pop(future) + reserve(item, -1) + result = future.result() + invalidate_digests(item.node) + if result.status == "succeeded" and item.node.cacheable: + try: + self._save(item, result.output_manifest) + except Exception as error: + result = state_failure(item, result, error) + finish(item.node.name, result) + failed_now = failed_now or result.status == "failed" + if failed_now and self.graph.failure_policy == "fail-fast": + aborting = True + cancellation.set() + running_names = {item.node.name for item in running.values()} + for name in ordered: + if name not in results and name not in running_names: + results[name] = self._record( + placeholder(name), + logs_dir, + "cancelled", + "fail-fast policy", + ) + ready.clear() + elif ready and not dispatch_progress: + raise RuntimeError("validated scheduler made no progress") + + for name in ordered: + item = ensure_item(name) + if results[name].fingerprint != item.fingerprint: + results[name] = replace(results[name], fingerprint=item.fingerprint) + plan = tuple(items[name] for name in ordered) + return RunSummary(plan, results, time.perf_counter() - started) + + +def _expand(value, variables): + if isinstance(value, str): + def replace(match): + try: + return variables[match.group(1)] + except KeyError as error: + raise GraphValidationError(f"unknown variable: {error.args[0]}") from error + + return VARIABLE.sub(replace, value) + if isinstance(value, list): + return [_expand(item, variables) for item in value] + if isinstance(value, dict): + return {key: _expand(item, variables) for key, item in value.items()} + return value + + +def _expand_nodes(raw_nodes: object, variables: Mapping[str, str]) -> list[object]: + if not isinstance(raw_nodes, list): + raise GraphValidationError("nodes are required") + expanded_nodes: list[object] = [] + for raw_node in raw_nodes: + if not isinstance(raw_node, dict): + raise GraphValidationError("every node must be an object") + matrix = raw_node.get("matrix") + if matrix is None: + expanded_nodes.append(_expand(raw_node, variables)) + continue + if not isinstance(matrix, dict) or set(matrix) != {"axes", "exclude"}: + raise GraphValidationError("matrix must contain exactly axes and exclude") + raw_axes = matrix["axes"] + if not isinstance(raw_axes, dict) or not raw_axes: + raise GraphValidationError("matrix axes must be a non-empty object") + axes: dict[str, tuple[str, ...]] = {} + for raw_name, raw_values in raw_axes.items(): + axis = _variable_name(raw_name, "matrix axis") + if axis in variables: + raise GraphValidationError(f"matrix axis {axis!r} collides with graph variable") + values = _strings(_expand(raw_values, variables), f"values for matrix axis {axis}") + if not values or any(not value for value in values) or len(values) != len(set(values)): + raise GraphValidationError(f"matrix axis {axis!r} needs unique non-empty values") + axes[axis] = tuple(sorted(values)) + + raw_excludes = matrix["exclude"] + if not isinstance(raw_excludes, list) or any(not isinstance(item, dict) for item in raw_excludes): + raise GraphValidationError("matrix exclude must be an array of exact assignments") + axis_names = tuple(sorted(axes)) + excludes: set[tuple[str, ...]] = set() + for raw_exclude in raw_excludes: + expanded_exclude = _expand(raw_exclude, variables) + if set(expanded_exclude) != set(axis_names): + raise GraphValidationError("matrix exclude must be an exact assignment of every axis") + assignment: list[str] = [] + for axis in axis_names: + value = expanded_exclude[axis] + if not isinstance(value, str) or value not in axes[axis]: + raise GraphValidationError("matrix exclude must be an exact known assignment") + assignment.append(value) + key = tuple(assignment) + if key in excludes: + raise GraphValidationError("duplicate matrix exclude assignment") + excludes.add(key) + + template = dict(raw_node) + template.pop("matrix") + for values in product(*(axes[axis] for axis in axis_names)): + if values in excludes: + continue + matrix_variables = {**variables, **dict(zip(axis_names, values, strict=True))} + expanded_nodes.append(_expand(template, matrix_variables)) + return expanded_nodes + + +def load_graph_profile(profile_path, graph_name, workspace, overrides=None): + try: + profile = json.loads(Path(profile_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise GraphValidationError(f"cannot read profile: {error}") from error + if not isinstance(profile, dict) or profile.get("schema") != SCHEMA: + raise GraphValidationError(f"profile schema must be {SCHEMA}") + selected = graph_name or profile.get("default_graph") + try: + raw = profile["graphs"][selected] + except (KeyError, TypeError) as error: + raise GraphValidationError(f"unknown graph: {selected!r}") from error + if not isinstance(raw, dict): + raise GraphValidationError("selected graph must be an object") + declared_variables = raw.get("variables", {}) + if not isinstance(declared_variables, dict) or any( + not isinstance(value, str) for value in declared_variables.values() + ): + raise GraphValidationError("variables must map names to strings") + for key in declared_variables: + _variable_name(key, "variable name") + if "python" in declared_variables: + raise GraphValidationError("python is a reserved variable") + variables = {"python": str(Path(sys.executable).resolve()), **declared_variables} + for key, value in (overrides or {}).items(): + if key not in declared_variables: + raise GraphValidationError(f"undeclared override: {key}") + variables[key] = value + + normalized = { + key: _expand(value, variables) + for key, value in raw.items() + if key not in {"variables", "nodes"} + } + normalized["nodes"] = _expand_nodes(raw.get("nodes"), variables) + return Graph.from_mapping(selected, normalized, Path(workspace)) + + +def plan_as_json(plan): + rows = [ + { + "name": item.node.name, + "deps": item.node.deps, + "run_after": item.node.run_after, + "resources": dict(item.node.resources), + "argv": item.node.argv, + "inputs": item.node.inputs, + "writes": item.node.writes, + "outputs": item.node.outputs, + "cacheable": item.node.cacheable, + "fingerprint": item.fingerprint, + } + for item in plan + ] + return json.dumps(rows, indent=2, sort_keys=True) + "\n" + + +def _parser(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("plan", "run"): + child = commands.add_parser(command) + child.add_argument("--profile", type=Path, default=Path(__file__).with_name("graph_profiles.json")) + child.add_argument("--graph") + child.add_argument("--workspace", type=Path, default=Path(__file__).resolve().parent.parent) + child.add_argument("--set", action="append", default=[], metavar="NAME=VALUE") + child.add_argument("--target", action="append", default=[]) + commands.choices["plan"].add_argument("--json", action="store_true") + commands.choices["run"].add_argument("--jobs", type=int) + return parser + + +def main(argv=None): + args = _parser().parse_args(argv) + try: + overrides = {} + for item in args.set: + name, separator, value = item.partition("=") + if not separator or not name or name in overrides: + raise GraphValidationError(f"invalid --set: {item!r}") + overrides[name] = value + graph = load_graph_profile(args.profile, args.graph, args.workspace, overrides) + targets = args.target or None + if args.command == "plan": + plan = graph.plan(targets) + if args.json: + print(plan_as_json(plan), end="") + else: + for index, item in enumerate(plan, 1): + policy = "cacheable" if item.node.cacheable else "always-run" + resources = ",".join(f"{name}={demand}" for name, demand in item.node.resources) + print( + f"{index:02d} {item.node.name} resources={resources} " + f"{policy} fingerprint={item.fingerprint[:16]}" + ) + return 0 + base = graph.workspace / ".artifacts" / "graph" / graph.name + summary = GraphRunner(graph, base / "logs", base / "state", max_workers=args.jobs).run(targets) + for item in summary.plan: + result = summary.results[item.node.name] + print(f"{result.status:9} {result.name} {result.duration_seconds:.3f}s log={result.log_path}") + print(f"total {summary.duration_seconds:.3f}s") + return 0 if summary.succeeded else 1 + except GraphValidationError as error: + print(f"graph error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/graph_profiles.json b/build/graph_profiles.json new file mode 100644 index 0000000..ca92e5b --- /dev/null +++ b/build/graph_profiles.json @@ -0,0 +1,190 @@ +{ + "schema": 1, + "default_graph": "observer-verify-shadow", + "graphs": { + "observer-verify-shadow": { + "variables": { + "arch": "x64", + "fuzz_seconds": "60" + }, + "pools": { + "normal-x64": 3, + "asan-x64": 1 + }, + "targets": ["package"], + "nodes": [ + { + "name": "doctor", + "deps": [], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "doctor"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=doctor"], + "cacheable": false + }, + { + "name": "restore", + "deps": ["doctor"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "restore", "-Arch", "{arch}", "-RestoreFlavor", "all"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "vcpkg.json", "vcpkg-configuration.json", "vcpkg/triplets/*.cmake"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v2", "flavors=default+asan", "leaf=restore"], + "cacheable": false + }, + { + "name": "source-checks", + "deps": ["restore"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "source-checks", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": [".clang-format", ".clang-tidy", "build.ps1", "build/**/*.ps1", "build/**/*.props", "src/**/*.cpp", "src/**/*.h"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=source-checks"], + "cacheable": false + }, + { + "name": "test-debug", + "deps": ["source-checks"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test", "-Arch", "{arch}", "-Config", "Debug", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["configuration=Debug", "contract=observer-shadow-v1", "leaf=test"], + "cacheable": false + }, + { + "name": "test-release", + "deps": ["source-checks"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test", "-Arch", "{arch}", "-Config", "Release", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["configuration=Release", "contract=observer-shadow-v1", "leaf=test"], + "cacheable": false + }, + { + "name": "compiler-analysis", + "deps": ["test-debug"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "compiler-analysis", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": [".clang-tidy", "build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=compiler-analysis"], + "cacheable": false + }, + { + "name": "coverage", + "deps": ["source-checks"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-coverage", "-Arch", "{arch}", "-CoverageThreshold", "100", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "coverage-threshold=100", "leaf=test-coverage"], + "cacheable": false + }, + { + "name": "asan", + "deps": ["source-checks"], + "pool": "asan-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-asan", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=test-asan"], + "cacheable": false + }, + { + "name": "ubsan", + "deps": ["source-checks"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-ubsan", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=test-ubsan"], + "cacheable": false + }, + { + "name": "leaks", + "deps": ["compiler-analysis", "test-release", "coverage", "ubsan", "fuzz"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-leaks", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=test-leaks"], + "cacheable": false + }, + { + "name": "fuzz", + "deps": ["asan"], + "pool": "asan-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "fuzz", "-Arch", "{arch}", "-FuzzTarget", "all", "-FuzzSeconds", "{fuzz_seconds}", "-SkipDependencyRestore"], + "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "fuzz-target=all", "leaf=fuzz"], + "cacheable": false + }, + { + "name": "package", + "deps": ["leaks"], + "pool": "normal-x64", + "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "package", "-Arch", "{arch}", "-SkipDependencyRestore"], + "inputs": ["LICENSE.txt", "build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "licenses/*.txt", "src/**/*", "vcpkg.json"], + "outputs": [], + "fingerprint": ["contract=observer-shadow-v1", "leaf=package"], + "cacheable": false + } + ] + }, + "synthetic-cache-benchmark": { + "variables": { + "run_id": "default" + }, + "pools": { + "cpu": 2 + }, + "targets": ["gate"], + "nodes": [ + { + "name": "prepare", + "deps": [], + "pool": "cpu", + "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.05); p=Path('.artifacts/graph-benchmark/{run_id}/prepare.txt'); p.parent.mkdir(parents=True, exist_ok=True); p.write_text('prepared', encoding='utf-8')"], + "inputs": ["build/graph_driver.py"], + "outputs": [".artifacts/graph-benchmark/{run_id}/prepare.txt"], + "fingerprint": ["benchmark=prepare-v1"], + "cacheable": true + }, + { + "name": "compile-a", + "deps": ["prepare"], + "pool": "cpu", + "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.08); Path('.artifacts/graph-benchmark/{run_id}/compile-a.txt').write_text('a', encoding='utf-8')"], + "inputs": ["build/graph_driver.py"], + "outputs": [".artifacts/graph-benchmark/{run_id}/compile-a.txt"], + "fingerprint": ["benchmark=compile-a-v1"], + "cacheable": true + }, + { + "name": "compile-b", + "deps": ["prepare"], + "pool": "cpu", + "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.08); Path('.artifacts/graph-benchmark/{run_id}/compile-b.txt').write_text('b', encoding='utf-8')"], + "inputs": ["build/graph_driver.py"], + "outputs": [".artifacts/graph-benchmark/{run_id}/compile-b.txt"], + "fingerprint": ["benchmark=compile-b-v1"], + "cacheable": true + }, + { + "name": "gate", + "deps": ["compile-a", "compile-b"], + "pool": "cpu", + "argv": ["{python}", "-c", "import time; time.sleep(0.02); print('non-cacheable gate ran')"], + "inputs": ["build/graph_driver.py"], + "outputs": [], + "fingerprint": ["benchmark=gate-v1"], + "cacheable": false + } + ] + } + } +} diff --git a/build/ixdag/__init__.py b/build/ixdag/__init__.py new file mode 100644 index 0000000..b99dba0 --- /dev/null +++ b/build/ixdag/__init__.py @@ -0,0 +1,2 @@ +"""Small typed build-graph model inspired by pg83/ix.""" + diff --git a/build/ixdag/execute.py b/build/ixdag/execute.py new file mode 100644 index 0000000..43a38a5 --- /dev/null +++ b/build/ixdag/execute.py @@ -0,0 +1,366 @@ +# Copyright (c) pg83 contributors +# SPDX-License-Identifier: MIT +# +# Derived from pg83/ix core/execute.py (MIT): +# https://github.com/pg83/ix/blob/main/core/execute.py +# +# The adaptation preserves IX's demand-driven asyncio visitor, per-node lock, +# named semaphore pools, immutable output directories, completion marker, and +# trash-before-retry behavior. Windows changes are intentionally confined to +# paths and process creation. + +"""Small, Windows-capable, IX-derived content-addressed DAG executor. + +The default runner waits for and can terminate only its direct child. Reliable +Windows process-tree cancellation remains blocked on a Job Object runner; the +``runner`` injection seam exists so that support does not affect DAG semantics. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import subprocess +import uuid + + +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z") +_OBJECT_ID = re.compile(r"[0-9a-f]{64}\Z") +_RESERVED_ENV = frozenset(("IX_NODE", "IX_OUT", "IX_POOL_CAPACITY")) + + +class GraphError(ValueError): + """The graph or its filesystem boundary is invalid.""" + + +class NodeExecutionError(RuntimeError): + """A node command did not complete successfully.""" + + +@dataclass(frozen=True) +class Command: + argv: tuple[str, ...] + cwd: str = "." + env: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class Node: + name: str + object_id: str + deps: tuple[str, ...] = () + pool: str = "cpu" + commands: tuple[Command, ...] = () + + +@dataclass(frozen=True) +class Graph: + nodes: tuple[Node, ...] + targets: tuple[str, ...] + pools: Mapping[str, int] + + +@dataclass(frozen=True) +class ProcessRequest: + node_name: str + pool: str + argv: tuple[str, ...] + cwd: Path + env: Mapping[str, str] + log_path: Path + + +@dataclass(frozen=True) +class NodeResult: + status: str + output_dir: Path + log_path: Path + + +ProcessRunner = Callable[[ProcessRequest], Awaitable[int]] + + +@dataclass +class _VisitState: + lock: asyncio.Lock + result: NodeResult | None = None + + +async def run_process(request: ProcessRequest) -> int: + """Run literal argv without a shell and merge output into the node log. + + Cancellation terminates the direct child and waits for it. A future Windows + Job Object runner can be injected for guaranteed descendant termination. + """ + + request.log_path.parent.mkdir(parents=True, exist_ok=True) + with request.log_path.open("ab", buffering=0) as stream: + process = await asyncio.create_subprocess_exec( + *request.argv, + cwd=str(request.cwd), + env=dict(request.env), + stdout=stream, + stderr=subprocess.STDOUT, + ) + try: + return await process.wait() + except asyncio.CancelledError: + if process.returncode is None: + process.terminate() + await process.wait() + raise + + +class Executor: + """Execute only target ancestors using the core pg83/ix visit algorithm.""" + + def __init__( + self, + graph: Graph, + workspace: Path, + state_root: Path, + *, + runner: ProcessRunner = run_process, + ) -> None: + self.graph = graph + self.workspace = workspace.resolve(strict=True) + self.state_root = state_root.resolve(strict=False) + if self.state_root == self.workspace or not self.state_root.is_relative_to(self.workspace): + raise GraphError("state root must be a strict child of workspace") + + self.runner = runner + self.nodes = self._validate_graph(graph) + self.pool_sizes = dict(graph.pools) + self.pools = {name: asyncio.Semaphore(size) for name, size in self.pool_sizes.items()} + self.visits = { + name: _VisitState(lock=asyncio.Lock()) + for name in self.nodes + } + self.objects_root = self.state_root / "objects" + self.logs_root = self.state_root / "logs" + self.trash_root = self.state_root / "trash" + for path in (self.objects_root, self.logs_root, self.trash_root): + path.mkdir(parents=True, exist_ok=True) + + async def run(self) -> dict[str, NodeResult]: + await self._visit_many(self.graph.targets) + return { + name: state.result + for name, state in self.visits.items() + if state.result is not None + } + + def output_dir(self, node_name: str) -> Path: + node = self.nodes[node_name] + return self.objects_root / node.object_id[:2] / node.object_id + + async def _visit(self, name: str) -> NodeResult: + state = self.visits[name] + async with state.lock: + if state.result is not None: + return state.result + + node = self.nodes[name] + if self._is_complete(node): + state.result = self._result(node, "cached", f"CACHE {name}\n") + return state.result + + await self._visit_many(node.deps) + async with self.pools[node.pool]: + state.result = await self._execute(node) + return state.result + + async def _visit_many(self, names: tuple[str, ...]) -> None: + tasks = [asyncio.create_task(self._visit(name)) for name in names] + try: + await asyncio.gather(*tasks) + except BaseException: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + async def _execute(self, node: Node) -> NodeResult: + out_dir = self.output_dir(node.name) + log_path = self._log_path(node) + self._prepare_dir(out_dir) + log_path.write_text(f"ENTER {node.name}\n", encoding="utf-8") + + try: + for command in node.commands: + request = ProcessRequest( + node_name=node.name, + pool=node.pool, + argv=command.argv, + cwd=self._command_cwd(command), + env=self._command_env(node, command, out_dir), + log_path=log_path, + ) + return_code = await self.runner(request) + if return_code != 0: + raise NodeExecutionError( + f"node {node.name!r} failed with exit code {return_code}; " + f"see {log_path}" + ) + + self._publish_marker(node, out_dir) + self._append_log(log_path, f"LEAVE {node.name}\n") + return NodeResult("executed", out_dir, log_path) + except asyncio.CancelledError: + self._append_log(log_path, f"CANCEL {node.name}\n") + self._move_to_trash(out_dir) + raise + except Exception as error: + self._append_log(log_path, f"ERROR {node.name}: {error}\n") + self._move_to_trash(out_dir) + if isinstance(error, NodeExecutionError): + raise + raise NodeExecutionError(f"node {node.name!r} failed; see {log_path}") from error + + def _validate_graph(self, graph: Graph) -> dict[str, Node]: + if not graph.nodes: + raise GraphError("graph must contain nodes") + nodes: dict[str, Node] = {} + object_ids: set[str] = set() + for node in graph.nodes: + if not _NAME.fullmatch(node.name): + raise GraphError(f"invalid node name: {node.name!r}") + if node.name in nodes: + raise GraphError(f"duplicate node: {node.name}") + if not _OBJECT_ID.fullmatch(node.object_id): + raise GraphError(f"node {node.name!r} has invalid object id") + if node.object_id in object_ids: + raise GraphError(f"duplicate object id: {node.object_id}") + self._validate_commands(node) + nodes[node.name] = node + object_ids.add(node.object_id) + + self._validate_references(graph, nodes) + self._validate_cycles(nodes) + return nodes + + def _validate_commands(self, node: Node) -> None: + for command in node.commands: + if not command.argv or any(not isinstance(arg, str) or "\0" in arg for arg in command.argv): + raise GraphError(f"node {node.name!r} has invalid argv") + keys = [key for key, _value in command.env] + if len(keys) != len(set(keys)): + raise GraphError(f"node {node.name!r} has duplicate environment keys") + if _RESERVED_ENV.intersection(keys): + raise GraphError(f"node {node.name!r} overrides reserved environment") + self._command_cwd(command) + + def _validate_references(self, graph: Graph, nodes: Mapping[str, Node]) -> None: + if not graph.targets: + raise GraphError("graph must contain targets") + unknown_targets = sorted(set(graph.targets).difference(nodes)) + if unknown_targets: + raise GraphError(f"unknown targets: {', '.join(unknown_targets)}") + for name, size in graph.pools.items(): + if not _NAME.fullmatch(name) or isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise GraphError(f"invalid pool: {name!r}") + for node in nodes.values(): + unknown = sorted(set(node.deps).difference(nodes)) + if unknown: + raise GraphError(f"node {node.name!r} has unknown dependencies: {', '.join(unknown)}") + if node.pool not in graph.pools: + raise GraphError(f"node {node.name!r} uses unknown pool {node.pool!r}") + + @staticmethod + def _validate_cycles(nodes: Mapping[str, Node]) -> None: + active: list[str] = [] + complete: set[str] = set() + + def visit(name: str) -> None: + if name in complete: + return + if name in active: + start = active.index(name) + raise GraphError("cycle: " + " -> ".join((*active[start:], name))) + active.append(name) + for dependency in nodes[name].deps: + visit(dependency) + active.pop() + complete.add(name) + + for name in nodes: + visit(name) + + def _command_cwd(self, command: Command) -> Path: + candidate = Path(command.cwd) + if not candidate.is_absolute(): + candidate = self.workspace / candidate + resolved = candidate.resolve(strict=False) + if not resolved.is_relative_to(self.workspace): + raise GraphError("command cwd must stay within workspace") + if not resolved.is_dir(): + raise GraphError(f"command cwd is not a directory: {resolved}") + return resolved + + def _command_env(self, node: Node, command: Command, out_dir: Path) -> dict[str, str]: + env = dict(os.environ) + env.update(command.env) + env.update( + { + "IX_NODE": node.name, + "IX_OUT": str(out_dir), + "IX_POOL_CAPACITY": str(self.pool_sizes[node.pool]), + } + ) + return env + + def _is_complete(self, node: Node) -> bool: + out_dir = self.output_dir(node.name) + if self._is_link(out_dir) or not out_dir.is_dir(): + return False + marker = out_dir / ".complete" + try: + return marker.is_file() and marker.read_text(encoding="utf-8") == self._marker_text(node) + except OSError: + return False + + def _prepare_dir(self, path: Path) -> None: + self._move_to_trash(path) + path.mkdir(parents=True, exist_ok=False) + + def _move_to_trash(self, path: Path) -> None: + if not os.path.lexists(path): + return + destination = self.trash_root / f"{path.name}-{uuid.uuid4().hex}" + os.replace(path, destination) + + @staticmethod + def _is_link(path: Path) -> bool: + is_junction = getattr(path, "is_junction", lambda: False) + return path.is_symlink() or is_junction() + + @staticmethod + def _marker_text(node: Node) -> str: + return json.dumps( + {"object_id": node.object_id, "schema": 1}, + sort_keys=True, + separators=(",", ":"), + ) + "\n" + + def _publish_marker(self, node: Node, out_dir: Path) -> None: + temporary = out_dir / f".complete-{uuid.uuid4().hex}.tmp" + temporary.write_text(self._marker_text(node), encoding="utf-8") + os.replace(temporary, out_dir / ".complete") + + def _log_path(self, node: Node) -> Path: + return self.logs_root / f"{node.name}-{node.object_id[:12]}.log" + + def _result(self, node: Node, status: str, message: str) -> NodeResult: + log_path = self._log_path(node) + log_path.write_text(message, encoding="utf-8") + return NodeResult(status, self.output_dir(node.name), log_path) + + @staticmethod + def _append_log(path: Path, message: str) -> None: + with path.open("a", encoding="utf-8") as stream: + stream.write(message) diff --git a/build/ixdag/graph.py b/build/ixdag/graph.py new file mode 100644 index 0000000..1a1b546 --- /dev/null +++ b/build/ixdag/graph.py @@ -0,0 +1,488 @@ +"""A compact, typed, content-addressed Observer micro-DAG. + +The descriptor/signature split follows the useful core of pg83/ix's MIT-licensed +``core/sign.py`` and ``core/gg.py``. Observer paths, Windows argv and validation +are intentionally new and much narrower than IX's package/realm implementation. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from hashlib import sha256 +import json +from pathlib import Path, PurePosixPath +import re + + +_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +_GLOB = re.compile(r"[*?\[\]]") +Renderer = Callable[[Mapping[str, object]], "Node"] + + +class GraphError(ValueError): + """Raised when a descriptor graph is ambiguous or unsafe.""" + + +def _relative_path(value: str) -> str: + path = PurePosixPath(value.replace("\\", "/")) + if not value or path.is_absolute() or ".." in path.parts: + raise GraphError(f"path must be repository-relative: {value!r}") + if _GLOB.search(value): + raise GraphError(f"exact paths cannot contain glob syntax: {value!r}") + return path.as_posix() + + +def _unique(values: Sequence[str], what: str) -> tuple[str, ...]: + result = tuple(values) + if len(result) != len(set(result)): + raise GraphError(f"duplicate {what}") + return result + + +@dataclass(frozen=True) +class TranslationUnit: + project: str + source: str + slug: str + + def __post_init__(self) -> None: + if not _NAME.fullmatch(self.project) or not _NAME.fullmatch(self.slug): + raise GraphError("translation-unit project and slug must be safe names") + object.__setattr__(self, "source", _relative_path(self.source)) + + +@dataclass(frozen=True) +class Project: + name: str + project_file: str + configurations: tuple[str, ...] + translation_units: tuple[TranslationUnit, ...] + + def __post_init__(self) -> None: + if not _NAME.fullmatch(self.name): + raise GraphError(f"unsafe project name: {self.name!r}") + object.__setattr__(self, "project_file", _relative_path(self.project_file)) + object.__setattr__( + self, "configurations", _unique(self.configurations, "project configuration") + ) + if not self.configurations or not self.translation_units: + raise GraphError(f"project {self.name!r} has an empty matrix axis") + if any(unit.project != self.name for unit in self.translation_units): + raise GraphError(f"translation unit belongs to another project: {self.name}") + _unique(tuple(unit.slug for unit in self.translation_units), "translation-unit slug") + + +@dataclass(frozen=True) +class ObserverMatrix: + shared_inputs: tuple[str, ...] + projects: tuple[Project, ...] + test_configurations: tuple[str, ...] + fuzz_targets: tuple[str, ...] + fuzz_seed_inputs: Mapping[str, tuple[str, ...]] + leak_modes: tuple[str, ...] + leak_scenarios: tuple[str, ...] + + def __post_init__(self) -> None: + object.__setattr__( + self, + "shared_inputs", + _unique(tuple(_relative_path(path) for path in self.shared_inputs), "shared input"), + ) + _unique(tuple(project.name for project in self.projects), "project name") + for axis_name, values in ( + ("test configuration", self.test_configurations), + ("fuzz target", self.fuzz_targets), + ("leak mode", self.leak_modes), + ("leak scenario", self.leak_scenarios), + ): + if not values or any(not _NAME.fullmatch(value.lower()) for value in values): + raise GraphError(f"invalid or empty {axis_name} axis") + _unique(values, axis_name) + if set(self.fuzz_seed_inputs) != set(self.fuzz_targets): + raise GraphError("fuzz seed inputs must exactly cover the fuzz-target axis") + normalized_seeds = { + target: _unique( + tuple(_relative_path(path) for path in self.fuzz_seed_inputs[target]), + f"{target} fuzz seed", + ) + for target in self.fuzz_targets + } + if any(not paths for paths in normalized_seeds.values()): + raise GraphError("every fuzz target needs at least one exact seed input") + object.__setattr__(self, "fuzz_seed_inputs", normalized_seeds) + + +@dataclass(frozen=True) +class Node: + name: str + deps: tuple[str, ...] + pool: str + argv: tuple[str, ...] + inputs: tuple[str, ...] + outputs: tuple[str, ...] + cacheable: bool = True + + def __post_init__(self) -> None: + if not _NAME.fullmatch(self.name): + raise GraphError(f"unsafe node name: {self.name!r}") + if not _NAME.fullmatch(self.pool): + raise GraphError(f"unsafe pool name: {self.pool!r}") + object.__setattr__(self, "deps", tuple(sorted(_unique(self.deps, "dependency")))) + object.__setattr__(self, "argv", tuple(self.argv)) + object.__setattr__( + self, "inputs", tuple(sorted(_unique(tuple(_relative_path(p) for p in self.inputs), "input"))) + ) + object.__setattr__( + self, + "outputs", + tuple(sorted(_unique(tuple(_relative_path(p) for p in self.outputs), "output"))), + ) + if not self.argv or not self.outputs: + raise GraphError(f"node {self.name!r} needs argv and at least one output") + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "Node": + return cls( + name=str(value["name"]), + deps=tuple(str(item) for item in value["deps"]), # type: ignore[union-attr] + pool=str(value["pool"]), + argv=tuple(str(item) for item in value["argv"]), # type: ignore[union-attr] + inputs=tuple(str(item) for item in value["inputs"]), # type: ignore[union-attr] + outputs=tuple(str(item) for item in value["outputs"]), # type: ignore[union-attr] + cacheable=bool(value.get("cacheable", True)), + ) + + def mapping(self) -> dict[str, object]: + return { + "name": self.name, + "deps": list(self.deps), + "pool": self.pool, + "argv": list(self.argv), + "inputs": list(self.inputs), + "outputs": list(self.outputs), + "cacheable": self.cacheable, + } + + +def direct_renderer(descriptor: Mapping[str, object]) -> Node: + """Test/bootstrap renderer; production uses the equivalent Jinja descriptor.""" + + return Node.from_mapping(descriptor) + + +def jinja_renderer(descriptor: Mapping[str, object]) -> Node: + """Render only repetitive JSON emission, keeping topology in typed Python.""" + + try: + from jinja2 import Environment, FileSystemLoader, StrictUndefined + except ImportError as error: + raise GraphError("Jinja2 is required to render ixdag descriptors") from error + template_root = Path(__file__).with_name("templates") + environment = Environment( + loader=FileSystemLoader(template_root), + autoescape=False, + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + rendered = environment.get_template("node.json.j2").render(descriptor=descriptor) + value = json.loads(rendered) + if not isinstance(value, dict): + raise GraphError("node template did not emit a JSON object") + return Node.from_mapping(value) + + +@dataclass(frozen=True) +class Graph: + nodes: tuple[Node, ...] + targets: tuple[str, ...] + + def __post_init__(self) -> None: + ordered = tuple(sorted(self.nodes, key=lambda node: node.name)) + object.__setattr__(self, "nodes", ordered) + names = _unique(tuple(node.name for node in ordered), "node name") + known = set(names) + object.__setattr__(self, "targets", tuple(sorted(_unique(self.targets, "target")))) + if not self.targets or any(target not in known for target in self.targets): + raise GraphError("targets must name nodes in the graph") + owners: dict[str, str] = {} + for node in ordered: + for output in node.outputs: + if output in owners: + raise GraphError(f"duplicate output owner: {output}") + owners[output] = node.name + for node in ordered: + if any(dep not in known or dep == node.name for dep in node.deps): + raise GraphError(f"unknown or self dependency in {node.name}") + for input_path in node.inputs: + owner = owners.get(input_path) + if owner is not None and owner not in node.deps: + raise GraphError( + f"generated input {input_path!r} is not a direct dependency of {node.name}" + ) + self._topological_names() + + def _topological_names(self) -> tuple[str, ...]: + by_name = {node.name: node for node in self.nodes} + visiting: set[str] = set() + visited: set[str] = set() + result: list[str] = [] + + def visit(name: str) -> None: + if name in visiting: + raise GraphError(f"dependency cycle at {name}") + if name in visited: + return + visiting.add(name) + for dependency in by_name[name].deps: + visit(dependency) + visiting.remove(name) + visited.add(name) + result.append(name) + + for node in self.nodes: + visit(node.name) + return tuple(result) + + def descriptors(self, workspace: Path) -> dict[str, dict[str, object]]: + """Seal nodes with source bytes and upstream UIDs, like IX store identities.""" + + root = workspace.resolve() + by_name = {node.name: node for node in self.nodes} + owners = {output: node.name for node in self.nodes for output in node.outputs} + sealed: dict[str, dict[str, object]] = {} + for name in self._topological_names(): + node = by_name[name] + content: list[dict[str, str]] = [] + for input_path in node.inputs: + if owner := owners.get(input_path): + content.append({"path": input_path, "producer": sealed[owner]["uid"]}) # type: ignore[dict-item] + continue + path = root.joinpath(*PurePosixPath(input_path).parts) + if not path.is_file(): + raise GraphError(f"exact source input does not exist: {input_path}") + content.append({"path": input_path, "sha256": sha256(path.read_bytes()).hexdigest()}) + descriptor = node.mapping() + payload = {"schema": "observer-ixdag-v1", "node": descriptor, "content": content} + uid = sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + sealed[name] = {**descriptor, "uid": uid} + return {name: sealed[name] for name in sorted(sealed)} + + +def _argv(action: str, *arguments: str) -> tuple[str, ...]: + return ("pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", action, *arguments) + + +def _binary_output(configuration: str, project: str) -> str: + extension = ".exe" if project in {"tests", "leak-probe"} or project.startswith("fuzz-") else ".so" + return f".artifacts/bin/x64/{configuration}/{project}{extension}" + + +def _build_name(configuration: str, project: str) -> str: + if configuration.lower() == "fuzz" and project.startswith("fuzz-"): + return f"build-{project}" + return f"build-{configuration.lower()}-{project}" + + +def build_observer_microdag( + matrix: ObserverMatrix, *, renderer: Renderer = jinja_renderer +) -> Graph: + """Expand all Observer axes into small independently schedulable leaves.""" + + nodes: list[Node] = [] + outputs: dict[str, tuple[str, ...]] = {} + + def add( + name: str, + deps: Sequence[str], + pool: str, + argv: Sequence[str], + sources: Sequence[str], + output: str, + *, + cacheable: bool = True, + ) -> str: + dependencies = tuple(sorted(deps)) + generated = tuple(path for dependency in dependencies for path in outputs[dependency]) + descriptor: dict[str, object] = { + "name": name, + "deps": dependencies, + "pool": pool, + "argv": tuple(argv), + "inputs": tuple(dict.fromkeys((*sources, *generated))), + "outputs": (output,), + "cacheable": cacheable, + } + node = renderer(descriptor) + nodes.append(node) + outputs[name] = node.outputs + return name + + source = add( + "source-checks", + (), + "checks", + _argv("source-checks"), + matrix.shared_inputs, + ".artifacts/reports/source-checks.json", + cacheable=False, + ) + build_names: dict[tuple[str, str], str] = {} + for project in matrix.projects: + project_sources = (project.project_file, *(unit.source for unit in project.translation_units), *matrix.shared_inputs) + for configuration in project.configurations: + name = _build_name(configuration, project.name) + build_names[(configuration.lower(), project.name)] = add( + name, + (source,), + "msbuild", + _argv( + "graph-leaf", + "-GraphLeafAction", + "build-project", + "-Arch", + "x64", + "-Config", + configuration, + "-Project", + project.name, + ), + project_sources, + _binary_output(configuration, project.name), + ) + + test_runs: list[str] = [] + for configuration in matrix.test_configurations: + key = configuration.lower() + dependencies = tuple( + build_names[(key, project.name)] + for project in matrix.projects + if (key, project.name) in build_names + and project.name != "leak-probe" + and not project.name.startswith("fuzz-") + ) + test_runs.append( + add( + f"run-tests-{key}", + dependencies, + "tests", + _argv("test", "-Arch", "x64", "-Config", configuration), + (), + f".artifacts/reports/tests/x64-{key}.xml", + cacheable=False, + ) + ) + + backend_merges: list[str] = [] + for backend, pool in (("msvc", "msvc-analysis"), ("tidy", "clang-tidy")): + normalizers: list[str] = [] + for project in matrix.projects: + for unit in project.translation_units: + stem = f"{backend}-{project.name}-{unit.slug}" + raw = f".artifacts/analysis/{backend}/x64/{project.name}/{unit.slug}.raw" + analyze = add( + f"analyze-{stem}", + (source,), + pool, + _argv("graph-leaf", "-GraphLeafAction", f"analyze-{backend}-unit", "-Project", project.name, "-SelectedFile", unit.source), + (project.project_file, unit.source, *matrix.shared_inputs), + raw, + ) + normalizers.append( + add( + f"normalize-{stem}", + (analyze,), + "sarif", + _argv("graph-leaf", "-GraphLeafAction", f"normalize-{backend}-sarif", "-Input", raw), + (), + f".artifacts/reports/{backend}/x64/units/{project.name}-{unit.slug}.sarif", + ) + ) + backend_merges.append( + add( + f"merge-{backend}-sarif", + normalizers, + "sarif", + _argv("graph-leaf", "-GraphLeafAction", "merge-sarif", "-Backend", backend), + (), + f".artifacts/reports/{backend}/x64/{backend}.sarif", + ) + ) + analysis_gate = add( + "analysis-gate", + backend_merges, + "sarif", + _argv("graph-leaf", "-GraphLeafAction", "analysis-gate"), + (), + ".artifacts/reports/analysis/x64/gate.json", + ) + + fuzz_runs: list[str] = [] + for target in matrix.fuzz_targets: + build = build_names[("fuzz", f"fuzz-{target}")] + replay = add( + f"fuzz-replay-{target}", + (build,), + "fuzz", + _argv("fuzz", "-Target", target, "-Phase", "replay"), + matrix.fuzz_seed_inputs[target], + f".artifacts/reports/fuzz/{target}/replay.json", + cacheable=False, + ) + fuzz_runs.append( + add( + f"fuzz-timed-{target}", + (build, replay), + "fuzz", + _argv("fuzz", "-Target", target, "-Phase", "timed"), + (), + f".artifacts/reports/fuzz/{target}/timed.json", + cacheable=False, + ) + ) + fuzz_gate = add( + "fuzz-gate", + fuzz_runs, + "gate", + _argv("fuzz", "-Phase", "aggregate"), + (), + ".artifacts/reports/fuzz/gate.json", + cacheable=False, + ) + + leak_probe = build_names[("release", "leak-probe")] + leak_cases = [ + add( + f"leak-case-{mode}-{scenario}", + (leak_probe,), + "umdh", + _argv("leaks", "-Mode", mode, "-Scenario", scenario), + (), + f".artifacts/reports/leaks/{mode}/{scenario}.json", + cacheable=False, + ) + for mode in matrix.leak_modes + for scenario in matrix.leak_scenarios + ] + leaks_gate = add( + "leaks-gate", + leak_cases, + "gate", + _argv("leaks", "-Phase", "aggregate"), + (), + ".artifacts/reports/leaks/gate.json", + cacheable=False, + ) + verify = add( + "verify", + (analysis_gate, fuzz_gate, leaks_gate, *test_runs), + "gate", + _argv("verify", "-Phase", "aggregate"), + (), + ".artifacts/reports/verify/gate.json", + cacheable=False, + ) + return Graph(tuple(nodes), (verify,)) diff --git a/build/ixdag/templates/node.json.j2 b/build/ixdag/templates/node.json.j2 new file mode 100644 index 0000000..7c40b5d --- /dev/null +++ b/build/ixdag/templates/node.json.j2 @@ -0,0 +1 @@ +{{ descriptor | tojson }} diff --git a/build/lib/analysis-reporting.ps1 b/build/lib/analysis-reporting.ps1 new file mode 100644 index 0000000..3f7480f --- /dev/null +++ b/build/lib/analysis-reporting.ps1 @@ -0,0 +1,179 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Set-MSVCAnalysisSarifIdentity { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)][string] $ReportDirectory, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture + ) + + if (-not (Test-Path -LiteralPath $ReportDirectory -PathType Container)) { + return + } + + $reportFiles = @(Get-ChildItem -LiteralPath $ReportDirectory -File -Filter '*.sarif' | Sort-Object Name) + foreach ($reportFile in $reportFiles) { + $sarif = Get-Content -Raw -LiteralPath $reportFile.FullName | ConvertFrom-Json -AsHashtable + if (-not $sarif.Contains('runs') -or $sarif['runs'] -isnot [System.Collections.IList]) { + throw "MSVC analysis report has no SARIF runs array: $($reportFile.FullName)" + } + + $reportName = [System.IO.Path]::GetFileNameWithoutExtension($reportFile.Name) + $runs = @($sarif['runs']) + for ($runIndex = 0; $runIndex -lt $runs.Count; ++$runIndex) { + $run = $runs[$runIndex] + if ($run -isnot [System.Collections.IDictionary]) { + throw "MSVC analysis report contains a non-object run: $($reportFile.FullName)" + } + + if (-not $run.Contains('automationDetails') -or $run['automationDetails'] -isnot [System.Collections.IDictionary]) { + $run['automationDetails'] = [ordered]@{} + } + $identitySuffix = if ($runs.Count -eq 1) { '' } else { "run-$($runIndex + 1)/" } + $run['automationDetails']['id'] = "msvc-analyze/$Architecture/$reportName/$identitySuffix" + } + + if ($PSCmdlet.ShouldProcess($reportFile.FullName, 'Assign stable MSVC SARIF run identities')) { + $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $reportFile.FullName -Encoding utf8 + } + } +} + +function Export-ClangTidySarif { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ObjectRoot, + [Parameter(Mandatory)][string] $OutputPath, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture + ) + + $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $repositoryPrefix = $resolvedRepositoryRoot + [System.IO.Path]::DirectorySeparatorChar + $diagnosticPattern = '^(?.+)\((?\d+),(?\d+)\):\s+(?warning|error)\s*:\s+(?.+?)\s*$' + $projectSuffixPattern = '^(?.*)\s+\[[^\]]+\.vcxproj\]$' + $ruleSuffixPattern = '^(?.*)\s+\[(?[^\]]+)\]$' + $deduplicationKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + $rules = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + $results = [System.Collections.Generic.List[object]]::new() + + $logFiles = @() + if (Test-Path -LiteralPath $ObjectRoot -PathType Container) { + $logFiles = @( + Get-ChildItem -LiteralPath $ObjectRoot -Recurse -File -Filter '*.ClangTidy.log' | + Sort-Object FullName + ) + } + + foreach ($logFile in $logFiles) { + foreach ($line in Get-Content -LiteralPath $logFile.FullName) { + $diagnosticMatch = [regex]::Match($line, $diagnosticPattern) + if (-not $diagnosticMatch.Success) { + continue + } + + $body = $diagnosticMatch.Groups['body'].Value + $projectMatch = [regex]::Match($body, $projectSuffixPattern) + if ($projectMatch.Success) { + $body = $projectMatch.Groups['diagnostic'].Value + } + $ruleMatch = [regex]::Match($body, $ruleSuffixPattern) + if (-not $ruleMatch.Success) { + continue + } + + $ruleId = @( + $ruleMatch.Groups['checks'].Value -split ',' | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('-', [System.StringComparison]::Ordinal) } + ) | Select-Object -First 1 + if (-not $ruleId) { + continue + } + + try { + $resolvedDiagnosticPath = [System.IO.Path]::GetFullPath($diagnosticMatch.Groups['path'].Value) + } catch { + continue + } + if (-not $resolvedDiagnosticPath.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $relativePath = [System.IO.Path]::GetRelativePath($resolvedRepositoryRoot, $resolvedDiagnosticPath). + Replace([System.IO.Path]::DirectorySeparatorChar, [char]'/') + $lineNumber = [int] $diagnosticMatch.Groups['line'].Value + $columnNumber = [int] $diagnosticMatch.Groups['column'].Value + $level = $diagnosticMatch.Groups['severity'].Value + $message = $ruleMatch.Groups['message'].Value.Trim() + $deduplicationKey = "$relativePath`n$lineNumber`n$columnNumber`n$level`n$ruleId`n$message" + if (-not $deduplicationKeys.Add($deduplicationKey)) { + continue + } + + if (-not $rules.ContainsKey($ruleId)) { + $rules.Add($ruleId, [ordered]@{ + id = $ruleId + name = $ruleId + shortDescription = [ordered]@{ text = "clang-tidy check $ruleId" } + }) + } + $results.Add([ordered]@{ + ruleId = $ruleId + level = $level + message = [ordered]@{ text = $message } + locations = @( + [ordered]@{ + physicalLocation = [ordered]@{ + artifactLocation = [ordered]@{ uri = $relativePath } + region = [ordered]@{ + startLine = $lineNumber + startColumn = $columnNumber + } + } + } + ) + }) + } + } + + $sortedResults = @( + $results | + Sort-Object ` + @{ Expression = { $_.locations[0].physicalLocation.artifactLocation.uri } }, ` + @{ Expression = { $_.locations[0].physicalLocation.region.startLine } }, ` + @{ Expression = { $_.locations[0].physicalLocation.region.startColumn } }, ` + ruleId, ` + @{ Expression = { $_.message.text } } + ) + $sortedRules = @($rules.Values | Sort-Object id) + $sarif = [ordered]@{ + version = '2.1.0' + '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' + runs = @( + [ordered]@{ + automationDetails = [ordered]@{ id = "clang-tidy/$Architecture/" } + tool = [ordered]@{ + driver = [ordered]@{ + name = 'clang-tidy' + informationUri = 'https://clang.llvm.org/extra/clang-tidy/' + rules = $sortedRules + } + } + results = $sortedResults + } + ) + } + + $outputDirectory = Split-Path $OutputPath -Parent + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null + $sarif | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +} diff --git a/build/lib/common.ps1 b/build/lib/common.ps1 new file mode 100644 index 0000000..e368837 --- /dev/null +++ b/build/lib/common.ps1 @@ -0,0 +1,106 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Write-Step { + param([Parameter(Mandatory)][string] $Message) + Write-Host "`n==> $Message" -ForegroundColor Cyan +} + +function Invoke-Native { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter()][string[]] $Arguments = @(), + [Parameter()][string] $WorkingDirectory = $script:RepositoryRoot + ) + + Push-Location $WorkingDirectory + try { + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')" + } + } finally { + Pop-Location + } +} + +function Invoke-NativeCapture { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter()][string[]] $Arguments = @(), + [Parameter()][string] $WorkingDirectory = $script:RepositoryRoot + ) + + Push-Location $WorkingDirectory + try { + $output = @(& $FilePath @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')`n$($output -join "`n")" + } + return @($output | ForEach-Object { $_.ToString() }) + } finally { + Pop-Location + } +} + +function Get-RequestedArchitecture { + param([Parameter(Mandatory)][string[]] $Requested) + + $requested = @( + $Requested | + ForEach-Object { $_ -split ',' } | + ForEach-Object { $_.Trim().ToLowerInvariant() } | + Where-Object { $_ } + ) + + if ($requested -contains 'all') { + return $script:KnownArchitectures + } + + foreach ($item in $requested) { + if ($item -notin $script:KnownArchitectures) { + throw "Unknown architecture '$item'. Expected x86, x64, arm64, or all." + } + } + + return @($requested | Select-Object -Unique) +} + +function Get-MSBuildPlatform { + param([Parameter(Mandatory)][string] $Architecture) + + switch ($Architecture) { + 'x86' { return 'Win32' } + 'x64' { return 'x64' } + 'arm64' { return 'ARM64' } + default { throw "Unsupported architecture '$Architecture'." } + } +} + +function Get-VcpkgTriplet { + param( + [Parameter(Mandatory)][string] $Architecture, + [Parameter()][ValidateSet('default', 'asan')][string] $Flavor = 'default' + ) + + $suffix = if ($Flavor -eq 'asan') { '-asan' } else { '' } + return "observer-$Architecture-windows-static$suffix" +} + +function Get-BinaryDirectory { + param( + [Parameter(Mandatory)][string] $Architecture, + [Parameter(Mandatory)][string] $Configuration + ) + return Join-Path $script:ArtifactsRoot "bin\$Architecture\$Configuration" +} + +function Resolve-UserPath { + param([Parameter(Mandatory)][string] $Path) + + if ([System.IO.Path]::IsPathFullyQualified($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + return [System.IO.Path]::GetFullPath((Join-Path $script:RepositoryRoot $Path)) +} diff --git a/build/lib/dynamic-graph-leaves.ps1 b/build/lib/dynamic-graph-leaves.ps1 new file mode 100644 index 0000000..8354452 --- /dev/null +++ b/build/lib/dynamic-graph-leaves.ps1 @@ -0,0 +1,454 @@ +#requires -Version 7.4 + +[CmdletBinding()] +param( + [Parameter()][ValidateSet('', 'fuzz', 'leak', 'audit', 'package')][string] $Leaf = '', + [Parameter()][string] $Phase, + [Parameter()][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture = 'x64', + [Parameter()][ValidateSet('pickle', 'renpy', 'rpgmaker', 'zanzarah')][string] $TargetName = 'pickle', + [Parameter()][ValidateSet('operations', 'lifecycle')][string] $Mode = 'operations', + [Parameter()][ValidateSet( + 'small-success', + 'malformed', + 'cancellation', + 'read-failure', + 'write-failure', + 'large-metadata', + 'sparse-metadata' + )][string] $Scenario = 'small-success', + [Parameter()][ValidateRange(1, 1000000)][int] $Warmup = 8, + [Parameter()][ValidateRange(1, 1000000)][int] $Iterations = 100, + [Parameter()][ValidateRange(3, 10)][int] $Windows = 3, + [Parameter()][ValidateRange(1, 86400)][int] $Seconds = 60, + [Parameter()][string] $Label, + [Parameter()][string] $RunId +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$null = $Warmup, $Iterations, $Windows, $Label + +$script:DynamicFuzzTargetSpecs = @( + [pscustomobject]@{ Name = 'pickle'; MaxLength = 262144 }, + [pscustomobject]@{ Name = 'renpy'; MaxLength = 1048576 }, + [pscustomobject]@{ Name = 'rpgmaker'; MaxLength = 1048576 }, + [pscustomobject]@{ Name = 'zanzarah'; MaxLength = 1048576 } +) +$script:DynamicLeakScenarioNames = @( + 'small-success', + 'malformed', + 'cancellation', + 'read-failure', + 'write-failure', + 'large-metadata', + 'sparse-metadata' +) +$script:DynamicRepositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + +function Assert-DynamicRunId { + param([Parameter(Mandatory)][string] $RunId) + + if ($RunId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { + throw "Dynamic run ID must be a safe 1-128 character path component: '$RunId'." + } +} + +function Get-DynamicFuzzTargetSpec { + param([Parameter()][string] $TargetName) + + if (-not $TargetName) { + return $script:DynamicFuzzTargetSpecs + } + $result = @($script:DynamicFuzzTargetSpecs | Where-Object Name -CEQ $TargetName) + if ($result.Count -ne 1) { + throw "Unknown dynamic fuzz target: $TargetName" + } + return $result[0] +} + +function Get-DynamicFuzzArgumentList { + param( + [Parameter(Mandatory)][ValidateSet('seed-replay', 'timed')][string] $Phase, + [Parameter(Mandatory)][string] $TargetName, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $InputPath, + [Parameter(Mandatory)][string] $ArtifactDirectory, + [Parameter()] $Seconds + ) + + $spec = Get-DynamicFuzzTargetSpec -TargetName $TargetName + if ($InputPath.Count -eq 0 -or @($InputPath | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -ne 0) { + throw 'Dynamic fuzz input paths must be non-empty.' + } + if ([string]::IsNullOrWhiteSpace($ArtifactDirectory)) { + throw 'Dynamic fuzz artifact directory must be non-empty.' + } + + $artifactPrefix = $ArtifactDirectory.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $arguments = [System.Collections.Generic.List[string]]::new() + foreach ($inputPathItem in $InputPath) { + $arguments.Add($inputPathItem) + } + $arguments.Add("-max_len=$($spec.MaxLength)") + $arguments.Add('-rss_limit_mb=1024') + $arguments.Add('-timeout=10') + $arguments.Add('-print_final_stats=1') + $arguments.Add("-artifact_prefix=$artifactPrefix") + if ($Phase -eq 'timed') { + if ($null -eq $Seconds -or $Seconds -is [bool] -or $Seconds -isnot [int] -or $Seconds -lt 1 -or $Seconds -gt 86400) { + throw 'Timed fuzzing requires Seconds from 1 to 86400.' + } + if ($InputPath.Count -ne 1) { + throw 'Timed fuzzing requires exactly one writable corpus directory.' + } + $arguments.Add("-max_total_time=$Seconds") + $arguments.Add('-use_value_profile=1') + } + return ,$arguments.ToArray() +} + +function Initialize-DynamicFuzzCorpus { + param( + [Parameter(Mandatory)][string] $SeedDirectory, + [Parameter(Mandatory)][string] $CorpusDirectory + ) + + $resolvedSeedDirectory = [System.IO.Path]::GetFullPath($SeedDirectory) + $resolvedCorpusDirectory = [System.IO.Path]::GetFullPath($CorpusDirectory) + if (-not (Test-Path -LiteralPath $resolvedSeedDirectory -PathType Container)) { + throw "Fuzzer seed directory was not found: $resolvedSeedDirectory" + } + if ($resolvedCorpusDirectory.Equals($resolvedSeedDirectory, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Fuzzer seed and corpus directories must be different.' + } + + $plans = [System.Collections.Generic.List[object]]::new() + $destinations = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($seed in @(Get-ChildItem -LiteralPath $resolvedSeedDirectory -File | Sort-Object Name)) { + if (($seed.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Fuzzer seed must not be a reparse point: $($seed.FullName)" + } + if ($seed.Length -gt 16MB) { + throw "Fuzzer seed exceeds the 16 MiB preparation bound: $($seed.FullName)" + } + + $destinationName = if ($seed.Extension -CEQ '.hex') { $seed.BaseName } else { $seed.Name } + if (-not $destinations.Add($destinationName)) { + throw "Fuzzer seeds map to a duplicate corpus name: $destinationName" + } + $bytes = if ($seed.Extension -CEQ '.hex') { + $hex = (Get-Content -Raw -LiteralPath $seed.FullName) -replace '\s', '' + if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { + throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" + } + [Convert]::FromHexString($hex) + } + else { + [System.IO.File]::ReadAllBytes($seed.FullName) + } + $plans.Add([pscustomobject]@{ Name = $destinationName; Bytes = $bytes }) + } + if ($plans.Count -eq 0) { + throw "No checked-in fuzzer seeds were found in: $resolvedSeedDirectory" + } + + New-Item -ItemType Directory -Force -Path $resolvedCorpusDirectory | Out-Null + foreach ($plan in $plans) { + [System.IO.File]::WriteAllBytes((Join-Path $resolvedCorpusDirectory $plan.Name), $plan.Bytes) + } + return @($plans | ForEach-Object { Join-Path $resolvedCorpusDirectory $_.Name }) +} + +function Write-DynamicLeafJson { + param( + [Parameter(Mandatory)] $Value, + [Parameter(Mandatory)][string] $Path + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + $parent = [System.IO.Path]::GetDirectoryName($resolvedPath) + if ([string]::IsNullOrEmpty($parent)) { + throw "Dynamic leaf evidence path has no parent: $resolvedPath" + } + New-Item -ItemType Directory -Force -Path $parent | Out-Null + $temporaryPath = "$resolvedPath.$([guid]::NewGuid().ToString('N')).tmp" + try { + $json = $Value | ConvertTo-Json -Depth 8 + [System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::Move($temporaryPath, $resolvedPath, $true) + } + finally { + if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { + Remove-Item -LiteralPath $temporaryPath -Force + } + } +} + +function Invoke-DynamicNativeProcess { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter(Mandatory)][string[]] $Arguments, + [Parameter(Mandatory)][string] $WorkingDirectory, + [Parameter(Mandatory)][string] $LogPath + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $WorkingDirectory + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Environment['ASAN_OPTIONS'] = 'halt_on_error=1:alloc_dealloc_mismatch=1' + foreach ($argument in $Arguments) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Failed to start dynamic native leaf: $FilePath" + } + $standardOutput = $process.StandardOutput.ReadToEndAsync() + $standardError = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $combinedOutput = $standardOutput.GetAwaiter().GetResult() + $standardError.GetAwaiter().GetResult() + $logParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($LogPath)) + New-Item -ItemType Directory -Force -Path $logParent | Out-Null + [System.IO.File]::WriteAllText($LogPath, $combinedOutput, [System.Text.UTF8Encoding]::new($false)) + return $process.ExitCode + } + finally { + $process.Dispose() + } +} + +function Invoke-DynamicFuzzLeaf { + param( + [Parameter(Mandatory)][ValidateSet('seed-replay', 'timed')][string] $Phase, + [Parameter(Mandatory)][string] $TargetName, + [Parameter(Mandatory)][string] $FuzzerPath, + [Parameter(Mandatory)][string] $SeedDirectory, + [Parameter(Mandatory)][string] $CorpusDirectory, + [Parameter(Mandatory)][string] $RunDirectory, + [Parameter(Mandatory)][string] $WorkingDirectory, + [Parameter(Mandatory)][ValidateRange(1, 86400)][int] $Seconds, + [Parameter()][scriptblock] $NativeInvoker = { + param($FilePath, $Arguments, $WorkingDirectory, $LogPath) + Invoke-DynamicNativeProcess ` + -FilePath $FilePath ` + -Arguments $Arguments ` + -WorkingDirectory $WorkingDirectory ` + -LogPath $LogPath + } + ) + + $resolvedFuzzer = [System.IO.Path]::GetFullPath($FuzzerPath) + $resolvedWorkingDirectory = [System.IO.Path]::GetFullPath($WorkingDirectory) + $resolvedRunDirectory = [System.IO.Path]::GetFullPath($RunDirectory) + if (-not (Test-Path -LiteralPath $resolvedFuzzer -PathType Leaf)) { + throw "Fuzzer executable was not found: $resolvedFuzzer" + } + if (-not (Test-Path -LiteralPath $resolvedWorkingDirectory -PathType Container)) { + throw "Fuzzer working directory was not found: $resolvedWorkingDirectory" + } + if (Test-Path -LiteralPath $resolvedRunDirectory) { + throw "Dynamic fuzz run directory already exists: $resolvedRunDirectory" + } + New-Item -ItemType Directory -Path $resolvedRunDirectory | Out-Null + $artifactDirectory = Join-Path $resolvedRunDirectory 'artifacts' + New-Item -ItemType Directory -Path $artifactDirectory | Out-Null + + $inputs = @( + if ($Phase -eq 'seed-replay') { + $replayDirectory = Join-Path $resolvedRunDirectory 'inputs' + Initialize-DynamicFuzzCorpus -SeedDirectory $SeedDirectory -CorpusDirectory $replayDirectory | + Sort-Object + } + else { + [void](Initialize-DynamicFuzzCorpus -SeedDirectory $SeedDirectory -CorpusDirectory $CorpusDirectory) + [System.IO.Path]::GetFullPath($CorpusDirectory) + } + ) + $argumentParameters = @{ + Phase = $Phase + TargetName = $TargetName + InputPath = $inputs + ArtifactDirectory = $artifactDirectory + } + if ($Phase -eq 'timed') { + $argumentParameters.Seconds = $Seconds + } + $arguments = Get-DynamicFuzzArgumentList @argumentParameters + $logPath = Join-Path $resolvedRunDirectory 'native.log' + $exitCode = & $NativeInvoker $resolvedFuzzer $arguments $resolvedWorkingDirectory $logPath + if ($exitCode -is [bool] -or $exitCode -isnot [int] -or $exitCode -ne 0) { + throw "Dynamic fuzz leaf failed for $TargetName $Phase with exit code '$exitCode'. Log: $logPath" + } + + $evidence = [ordered]@{ + phase = $Phase + target = $TargetName + inputCount = $inputs.Count + log = $logPath + passed = $true + } + Write-DynamicLeafJson -Value $evidence -Path (Join-Path $resolvedRunDirectory 'result.json') + return [pscustomobject]$evidence +} + +function Get-DynamicLeakScenarioName { + return $script:DynamicLeakScenarioNames +} + +function Assert-DynamicLeakScenario { + param([Parameter(Mandatory)][string] $Scenario) + + if ($Scenario -cnotin $script:DynamicLeakScenarioNames) { + throw "Unknown leak scenario: $Scenario" + } +} + +function Get-DynamicLeakProbeArgumentList { + param( + [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, + [Parameter(Mandatory)][string] $Scenario, + [Parameter(Mandatory)][ValidateRange(1, 1000000)][int] $Warmup, + [Parameter(Mandatory)][ValidateRange(1, 1000000)][int] $Iterations, + [Parameter(Mandatory)][ValidateRange(3, 10)][int] $Windows, + [Parameter()][switch] $Automatic + ) + + Assert-DynamicLeakScenario -Scenario $Scenario + $arguments = [System.Collections.Generic.List[string]]::new() + if ($Automatic) { + $arguments.Add('--automatic') + } + foreach ($argument in @( + '--mode', $Mode, + '--scenario', $Scenario, + '--warmup', [string]$Warmup, + '--iterations', [string]$Iterations, + '--windows', [string]$Windows + )) { + $arguments.Add($argument) + } + return ,$arguments.ToArray() +} + +function Assert-DynamicLeakReadyMarker { + param( + [Parameter(Mandatory)][string] $Line, + [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, + [Parameter(Mandatory)][string] $Scenario + ) + + Assert-DynamicLeakScenario -Scenario $Scenario + $pattern = '^OBSERVER_LEAK_PROBE\|READY\|pid=([1-9][0-9]*)\|mode=' + + [regex]::Escape($Mode) + '\|configuration=Release\|scenarios=' + [regex]::Escape($Scenario) + '$' + if ($Line -cnotmatch $pattern) { + throw "Leak probe READY marker does not match mode '$Mode' and scenario '$Scenario'." + } + return [pscustomobject]@{ + ProcessId = [int]$Matches[1] + Mode = $Mode + Scenario = $Scenario + } +} + +function Invoke-DynamicLeakPreflightLeaf { + param( + [Parameter(Mandatory)][string] $ProbePath, + [Parameter(Mandatory)][string] $BinaryDirectory, + [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, + [Parameter(Mandatory)][string] $Scenario, + [Parameter(Mandatory)][string] $EvidencePath, + [Parameter()][scriptblock] $NativeCapture = { + param($FilePath, $Arguments, $WorkingDirectory) + $null = $WorkingDirectory + $output = & $FilePath @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Leak preflight failed with exit code $LASTEXITCODE." + } + return @($output | ForEach-Object ToString) + } + ) + + $resolvedProbe = [System.IO.Path]::GetFullPath($ProbePath) + $resolvedBinaryDirectory = [System.IO.Path]::GetFullPath($BinaryDirectory) + if (-not (Test-Path -LiteralPath $resolvedProbe -PathType Leaf)) { + throw "Leak probe was not found: $resolvedProbe" + } + if (-not (Test-Path -LiteralPath $resolvedBinaryDirectory -PathType Container)) { + throw "Leak probe binary directory was not found: $resolvedBinaryDirectory" + } + $arguments = Get-DynamicLeakProbeArgumentList ` + -Mode $Mode ` + -Scenario $Scenario ` + -Warmup 1 ` + -Iterations 1 ` + -Windows 3 ` + -Automatic + $output = @(& $NativeCapture $resolvedProbe $arguments $resolvedBinaryDirectory) + $errors = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|ERROR|', [StringComparison]::Ordinal) }) + if ($errors.Count -ne 0) { + throw "Leak preflight reported an error: $($errors -join '; ')" + } + $readyLines = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|READY|', [StringComparison]::Ordinal) }) + if ($readyLines.Count -ne 1) { + throw "Leak preflight expected one READY marker, received $($readyLines.Count)." + } + $ready = Assert-DynamicLeakReadyMarker -Line $readyLines[0] -Mode $Mode -Scenario $Scenario + $donePattern = '^OBSERVER_LEAK_PROBE\|DONE\|pid=' + $ready.ProcessId + '\|completed_operations=([1-9][0-9]*)$' + $doneLines = @($output | Where-Object { $_ -cmatch $donePattern }) + if ($doneLines.Count -ne 1) { + throw "Leak preflight expected one matching DONE marker, received $($doneLines.Count)." + } + + $evidence = [ordered]@{ + mode = $Mode + scenario = $Scenario + processId = $ready.ProcessId + passed = $true + } + Write-DynamicLeafJson -Value $evidence -Path $EvidencePath + return [pscustomobject]$evidence +} + +if ($Leaf -eq 'fuzz') { + if ($Architecture -ne 'x64') { + throw 'Dynamic libFuzzer leaves are intentionally x64-only.' + } + if ($Phase -notin @('seed-replay', 'timed')) { + throw "Unknown dynamic fuzz phase: '$Phase'." + } + Assert-DynamicRunId -RunId $RunId + $binaryDirectory = Join-Path $script:DynamicRepositoryRoot '.artifacts\bin\x64\Fuzz' + $targetRoot = Join-Path $script:DynamicRepositoryRoot ".artifacts\fuzz\x64\$TargetName" + $runDirectory = Join-Path $script:DynamicRepositoryRoot ".artifacts\fuzz-runs\$RunId\x64\$TargetName\$Phase" + Invoke-DynamicFuzzLeaf ` + -Phase $Phase ` + -TargetName $TargetName ` + -FuzzerPath (Join-Path $binaryDirectory "fuzz-$TargetName.exe") ` + -SeedDirectory (Join-Path $script:DynamicRepositoryRoot "src\fuzz\corpus\$TargetName") ` + -CorpusDirectory (Join-Path $targetRoot 'corpus') ` + -RunDirectory $runDirectory ` + -WorkingDirectory $binaryDirectory ` + -Seconds $Seconds | Out-Null +} +elseif ($Leaf -eq 'leak' -and $Phase -eq 'preflight') { + if ($Architecture -ne 'x64') { + throw 'Dynamic UMDH leak leaves are intentionally x64-only.' + } + $binaryDirectory = Join-Path $script:DynamicRepositoryRoot '.artifacts\bin\x64\Release' + $evidencePath = Join-Path $script:DynamicRepositoryRoot ".artifacts\reports\leaks\x64\preflight\$Mode\$Scenario.json" + Invoke-DynamicLeakPreflightLeaf ` + -ProbePath (Join-Path $binaryDirectory 'leak-probe.exe') ` + -BinaryDirectory $binaryDirectory ` + -Mode $Mode ` + -Scenario $Scenario ` + -EvidencePath $evidencePath | Out-Null +} +elseif ($Leaf) { + throw "Dynamic leaf '$Leaf' phase '$Phase' is not wired to native execution yet." +} diff --git a/build/lib/graph-leaves.ps1 b/build/lib/graph-leaves.ps1 new file mode 100644 index 0000000..2c22d51 --- /dev/null +++ b/build/lib/graph-leaves.ps1 @@ -0,0 +1,476 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +$script:GraphProjectNames = @( + 'renpy', + 'rpgmaker', + 'zanzarah', + 'tests', + 'fuzz-pickle', + 'fuzz-renpy', + 'fuzz-rpgmaker', + 'fuzz-zanzarah', + 'leak-probe' +) + +function Get-GraphMSBuildPlatform { + param([Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture) + + switch ($Architecture) { + 'x86' { return 'Win32' } + 'x64' { return 'x64' } + 'arm64' { return 'ARM64' } + } +} + +function Resolve-GraphProjectPath { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $Project + ) + + if ($Project -notin $script:GraphProjectNames) { + throw "Native graph project '$Project' is not allowlisted." + } + $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) + $projectPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot "build\projects\$Project.vcxproj")) + $expectedProjectRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot 'build\projects')) + if (-not $projectPath.StartsWith($expectedProjectRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Native graph project escaped the project root: $projectPath" + } + if (-not (Test-Path -LiteralPath $projectPath -PathType Leaf)) { + throw "Native graph project was not found: $projectPath" + } + return $projectPath +} + +function Get-GraphProjectTranslationUnit { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $Project + ) + + $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project + [xml] $projectXml = Get-Content -Raw -LiteralPath $projectPath + $namespace = [System.Xml.XmlNamespaceManager]::new($projectXml.NameTable) + $namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + $prefix = '$(RepositoryRoot)' + return @( + $projectXml.SelectNodes('//msb:ClCompile[@Include]', $namespace) | + ForEach-Object { + $include = $_.Include.ToString() + if (-not $include.StartsWith($prefix, [System.StringComparison]::Ordinal)) { + throw "Native graph ClCompile path must begin with '$prefix': $include" + } + $relativePath = $include.Substring($prefix.Length).Replace('\', '/') + if ( + [System.IO.Path]::IsPathFullyQualified($relativePath) -or + @($relativePath -split '/') -contains '..' -or + -not $relativePath.EndsWith('.cpp', [System.StringComparison]::OrdinalIgnoreCase) + ) { + throw "Unsafe native graph ClCompile path: $relativePath" + } + $relativePath + } + ) +} + +function Get-GraphTranslationUnitSlug { + param([Parameter(Mandatory)][string] $Source) + + $normalizedSource = $Source.Replace('\', '/') + if ( + [System.IO.Path]::IsPathFullyQualified($normalizedSource) -or + @($normalizedSource -split '/') -contains '..' -or + -not $normalizedSource.EndsWith('.cpp', [System.StringComparison]::OrdinalIgnoreCase) + ) { + throw "Unsafe translation-unit path: $Source" + } + $readable = $normalizedSource + if ($readable.StartsWith('src/', [System.StringComparison]::Ordinal)) { + $readable = $readable.Substring(4) + } + $readable = $readable.Substring(0, $readable.Length - 4).ToLowerInvariant() + $readable = [regex]::Replace($readable, '[^a-z0-9]+', '-').Trim('-') + $digestBytes = [System.Security.Cryptography.SHA256]::HashData( + [System.Text.Encoding]::UTF8.GetBytes($normalizedSource) + ) + $digest = [System.Convert]::ToHexString($digestBytes).ToLowerInvariant().Substring(0, 8) + return "$readable-$digest" +} + +function Assert-GraphProjectUnit { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Unit + ) + + $expectedUnits = @( + Get-GraphProjectTranslationUnit -RepositoryRoot $RepositoryRoot -Project $Project | + ForEach-Object { Get-GraphTranslationUnitSlug -Source $_ } + ) + if ($Unit -cnotin $expectedUnits) { + throw "Translation-unit identity '$Unit' is not a ClCompile unit in $Project.vcxproj." + } +} + +function Assert-GraphProjectConfiguration { + param( + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Configuration + ) + + if ($Project -eq 'leak-probe' -and $Configuration -ne 'Release') { + throw 'The leak-probe graph leaf supports only Release|x64.' + } + if ($Project.StartsWith('fuzz-', [System.StringComparison]::Ordinal) -and $Configuration -ne 'Fuzz') { + throw "Fuzz graph project '$Project' requires the Fuzz configuration." + } + if ( + -not $Project.StartsWith('fuzz-', [System.StringComparison]::Ordinal) -and + $Project -ne 'leak-probe' -and + $Configuration -eq 'Fuzz' + ) { + throw "Non-fuzz graph project '$Project' cannot use the Fuzz configuration." + } +} + +function Get-GraphBuildProjectRequest { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][ValidateSet('Debug', 'Release', 'Coverage', 'ASan', 'UBSan', 'Fuzz')][string] $Configuration, + [Parameter(Mandatory)][string] $Project + ) + + $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project + Assert-GraphProjectConfiguration -Project $Project -Configuration $Configuration + if (($Configuration -in @('ASan', 'Fuzz')) -and $Architecture -eq 'arm64') { + throw "$Configuration is not supported for ARM64." + } + if (($Configuration -eq 'UBSan' -or $Project -eq 'leak-probe') -and $Architecture -ne 'x64') { + throw "$Configuration/$Project is supported only for x64." + } + + return [pscustomobject]@{ + ProjectPath = $projectPath + Target = 'Build' + Architecture = $Architecture + Platform = Get-GraphMSBuildPlatform -Architecture $Architecture + Configuration = $Configuration + Properties = [ordered]@{} + } +} + +function Get-GraphAnalysisRequest { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][ValidateSet('msvc', 'clang-tidy')][string] $Backend, + [Parameter(Mandatory)][string] $Project, + [Parameter()][string] $SelectedFile, + [Parameter()][string] $Unit + ) + + $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project + $configuration = if ($Project -eq 'leak-probe') { 'Release' } else { 'Debug' } + if ($Project -eq 'leak-probe' -and $Architecture -ne 'x64') { + throw 'leak-probe analysis is supported only for x64.' + } + $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) + $properties = [ordered]@{ + ObserverRunCodeAnalysis = 'true' + RunCodeAnalysis = 'true' + ObserverCompileAnalysis = 'true' + ForceRebuild = 'true' + } + + if (-not $SelectedFile -or -not $Unit) { + throw "$Backend selected-file analysis requires SelectedFile and Unit." + } + $normalizedSource = $SelectedFile.Replace('\', '/') + $translationUnits = @(Get-GraphProjectTranslationUnit -RepositoryRoot $resolvedRepositoryRoot -Project $Project) + if ($normalizedSource -notin $translationUnits) { + throw "'$SelectedFile' is not a ClCompile Include item in $Project.vcxproj." + } + $expectedUnit = Get-GraphTranslationUnitSlug -Source $normalizedSource + if ($Unit -cne $expectedUnit) { + throw "Translation-unit key '$Unit' does not match '$expectedUnit'." + } + $selectedPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot $normalizedSource.Replace('/', '\'))) + $properties.SelectedFiles = $selectedPath + $properties.SelectedFilesBuildPCH = 'false' + $properties.SelectedFilesBuildModules = 'false' + + if ($Backend -eq 'msvc') { + $scratchRoot = Join-Path $resolvedRepositoryRoot ".artifacts\analysis\msvc\$Architecture\$Project\$Unit" + $properties.EnableMicrosoftCodeAnalysis = 'true' + $properties.ObserverEnableClangTidy = 'false' + $properties.IntDir = Join-Path $scratchRoot 'obj\' + $properties.ObserverAnalysisReportName = $Project + $properties.ObserverAnalysisReportPath = Join-Path $scratchRoot "$Project.sarif" + } else { + $scratchRoot = Join-Path $resolvedRepositoryRoot ".artifacts\analysis\clang-tidy\$Architecture\$Project\$Unit" + $properties.EnableMicrosoftCodeAnalysis = 'false' + $properties.ObserverEnableClangTidy = 'true' + $properties.IntDir = Join-Path $scratchRoot 'obj\' + $properties.ClangTidyLogFile = "$Project.ClangTidy.log" + } + + return [pscustomobject]@{ + ProjectPath = $projectPath + Target = 'ClCompile' + Architecture = $Architecture + Platform = Get-GraphMSBuildPlatform -Architecture $Architecture + Configuration = $configuration + Properties = $properties + } +} + +function Resolve-GraphArtifactPath { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $Path + ) + + $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) + $resolvedPath = if ([System.IO.Path]::IsPathFullyQualified($Path)) { + [System.IO.Path]::GetFullPath($Path) + } else { + [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot $Path)) + } + $artifactRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot '.artifacts')) + if (-not $resolvedPath.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Graph artifact path must stay below '$artifactRoot': $resolvedPath" + } + + $current = $resolvedPath + while ($current.StartsWith($artifactRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + if (Test-Path -LiteralPath $current) { + $attributes = [System.IO.File]::GetAttributes($current) + if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Graph artifact path crosses a reparse point: $current" + } + } + if ($current.Equals($artifactRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + break + } + $current = Split-Path $current -Parent + } + return $resolvedPath +} + +function Assert-GraphArtifactPath { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $ExpectedRelativePath, + [Parameter(Mandatory)][ValidateSet('input', 'output', 'object root')][string] $Role + ) + + $resolvedPath = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $Path + $expectedPath = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $ExpectedRelativePath + if (-not $resolvedPath.Equals($expectedPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Graph SARIF expected $Role path '$expectedPath', received '$resolvedPath'." + } + return $resolvedPath +} + +function Read-GraphSarifDocument { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $Description + ) + + $sarif = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable + if ( + $sarif -isnot [System.Collections.IDictionary] -or + -not $sarif.Contains('version') -or + $sarif['version'] -isnot [string] -or + $sarif['version'] -cne '2.1.0' + ) { + throw "$Description must use SARIF version exactly 2.1.0: $Path" + } + if ( + -not $sarif.Contains('runs') -or + $sarif['runs'] -isnot [System.Collections.IList] -or + $sarif['runs'].Count -eq 0 -or + @($sarif['runs'] | Where-Object { $_ -isnot [System.Collections.IDictionary] }).Count -ne 0 + ) { + throw "$Description runs must be a non-empty list of objects: $Path" + } + return $sarif +} + +function Convert-GraphMsvcSarif { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $InputPath, + [Parameter(Mandatory)][string] $OutputPath, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Unit + ) + + if ($Project -notin $script:GraphProjectNames) { + throw "Native graph project '$Project' is not allowlisted." + } + Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $Project -Unit $Unit + $resolvedInput = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $InputPath ` + -ExpectedRelativePath ".artifacts\analysis\msvc\$Architecture\$Project\$Unit\$Project.sarif" ` + -Role input + $resolvedOutput = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $OutputPath ` + -ExpectedRelativePath ".artifacts\reports\msvc\$Architecture\units\$Project-$Unit.sarif" ` + -Role output + if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) { + throw "MSVC unit SARIF was not found: $resolvedInput" + } + $sarif = Read-GraphSarifDocument -Path $resolvedInput -Description 'MSVC unit SARIF' + $runs = @($sarif['runs']) + for ($index = 0; $index -lt $runs.Count; ++$index) { + $run = $runs[$index] + if ( + -not $run.ContainsKey('automationDetails') -or + $run['automationDetails'] -isnot [System.Collections.IDictionary] + ) { + $run['automationDetails'] = [ordered]@{} + } + $suffix = if ($runs.Count -eq 1) { '' } else { "run-$($index + 1)/" } + $run['automationDetails']['id'] = "msvc-analyze/$Architecture/$Project/$Unit/$suffix" + } + New-Item -ItemType Directory -Force -Path (Split-Path $resolvedOutput -Parent) | Out-Null + $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 +} + +function Convert-GraphClangTidyUnitSarif { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ObjectRoot, + [Parameter(Mandatory)][string] $OutputPath, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Unit + ) + + if ($Project -notin $script:GraphProjectNames) { + throw "Native graph project '$Project' is not allowlisted." + } + Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $Project -Unit $Unit + $resolvedObjectRoot = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $ObjectRoot ` + -ExpectedRelativePath ".artifacts\analysis\clang-tidy\$Architecture\$Project\$Unit\obj" ` + -Role 'object root' + $resolvedOutput = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $OutputPath ` + -ExpectedRelativePath ".artifacts\reports\clang-tidy\$Architecture\units\$Project-$Unit.sarif" ` + -Role output + Export-ClangTidySarif ` + -RepositoryRoot $RepositoryRoot ` + -ObjectRoot $resolvedObjectRoot ` + -OutputPath $resolvedOutput ` + -Architecture $Architecture + + $sarif = Read-GraphSarifDocument -Path $resolvedOutput -Description 'Clang-tidy unit SARIF' + $runs = @($sarif['runs']) + for ($index = 0; $index -lt $runs.Count; ++$index) { + $run = $runs[$index] + if ( + -not $run.ContainsKey('automationDetails') -or + $run['automationDetails'] -isnot [System.Collections.IDictionary] + ) { + $run['automationDetails'] = [ordered]@{} + } + $suffix = if ($runs.Count -eq 1) { '' } else { "run-$($index + 1)/" } + $run['automationDetails']['id'] = "clang-tidy/$Architecture/$Project/$Unit/$suffix" + } + $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 +} + +function Merge-GraphSarif { + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][ValidateSet('msvc', 'clang-tidy')][string] $Backend, + [Parameter(Mandatory)][string[]] $InputPaths, + [Parameter(Mandatory)][string] $OutputPath + ) + + if ($InputPaths.Count -eq 0) { + throw 'SARIF merge requires at least one input.' + } + $outputName = if ($Backend -eq 'msvc') { 'msvc-analyze.sarif' } else { 'clang-tidy.sarif' } + $resolvedOutput = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $OutputPath ` + -ExpectedRelativePath ".artifacts\reports\$Backend\$Architecture\$outputName" ` + -Role output + $expectedInputRoot = Resolve-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path ".artifacts\reports\$Backend\$Architecture\units" + $runs = [System.Collections.Generic.List[object]]::new() + foreach ($inputPath in $InputPaths) { + $resolvedInput = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $inputPath + $resolvedInputParent = Split-Path $resolvedInput -Parent + if (-not $resolvedInputParent.Equals($expectedInputRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Graph SARIF expected input path below '$expectedInputRoot', received '$resolvedInput'." + } + if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) { + throw "SARIF merge input was not found: $resolvedInput" + } + $inputName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedInput) + $project = @( + $script:GraphProjectNames | + Sort-Object Length -Descending | + Where-Object { $inputName.StartsWith("$_-", [System.StringComparison]::Ordinal) } + ) | Select-Object -First 1 + if (-not $project) { + throw "SARIF merge input does not identify an allowlisted project: $resolvedInput" + } + $unit = $inputName.Substring($project.Length + 1) + Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $project -Unit $unit + $expectedInput = Assert-GraphArtifactPath ` + -RepositoryRoot $RepositoryRoot ` + -Path $resolvedInput ` + -ExpectedRelativePath ".artifacts\reports\$Backend\$Architecture\units\$project-$unit.sarif" ` + -Role input + + $sarif = Read-GraphSarifDocument -Path $expectedInput -Description 'SARIF merge input' + $inputRuns = @($sarif['runs']) + $identityPrefix = if ($Backend -eq 'msvc') { 'msvc-analyze' } else { 'clang-tidy' } + for ($index = 0; $index -lt $inputRuns.Count; ++$index) { + $run = $inputRuns[$index] + $suffix = if ($inputRuns.Count -eq 1) { '' } else { "run-$($index + 1)/" } + $expectedIdentity = "$identityPrefix/$Architecture/$project/$unit/$suffix" + $automationDetails = $run['automationDetails'] + if ( + $automationDetails -isnot [System.Collections.IDictionary] -or + -not $automationDetails.Contains('id') -or + $automationDetails['id'] -isnot [string] -or + $automationDetails['id'] -cne $expectedIdentity + ) { + throw "SARIF merge input does not contain the exact expected unit identities: $resolvedInput" + } + $runs.Add($run) + } + } + $sortedRuns = @($runs | Sort-Object { $_['automationDetails']['id'] }) + $identities = @($sortedRuns | ForEach-Object { $_['automationDetails']['id'] }) + if (@($identities | Sort-Object -Unique).Count -ne $identities.Count) { + throw 'SARIF merge found duplicate automationDetails.id values.' + } + $merged = [ordered]@{ + version = '2.1.0' + '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' + runs = $sortedRuns + } + New-Item -ItemType Directory -Force -Path (Split-Path $resolvedOutput -Parent) | Out-Null + $merged | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 +} diff --git a/build/lib/package-manifest.ps1 b/build/lib/package-manifest.ps1 new file mode 100644 index 0000000..3dbfa31 --- /dev/null +++ b/build/lib/package-manifest.ps1 @@ -0,0 +1,213 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +$script:ObserverModulePackageEntries = @{ + renpy = @( + 'renpy.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/rpatool.txt' + 'docs/thirdparty/serde-pickle.txt' + 'docs/thirdparty/zlib.txt' + ) + rpgmaker = @( + 'rpgmaker.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/rgssad.txt' + ) + zanzarah = @( + 'zanzarah.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/zanzapak.txt' + ) +} +$script:ObserverSymbolsPackageEntries = @('renpy.pdb', 'rpgmaker.pdb', 'zanzarah.pdb') + +function Assert-SafeObserverZipEntryName { + param([Parameter(Mandatory)][AllowEmptyString()][string] $Name) + + $isUnsafe = [string]::IsNullOrEmpty($Name) -or + $Name.Length -gt 4096 -or + $Name[0] -eq '/' -or + $Name[0] -eq '\' -or + $Name.Contains('\', [System.StringComparison]::Ordinal) -or + $Name -match '^[A-Za-z]:' -or + -not $Name.IsNormalized([System.Text.NormalizationForm]::FormC) + if ($isUnsafe) { + throw "Package contains an unsafe ZIP entry name: '$Name'." + } + + foreach ($character in $Name.ToCharArray()) { + if ([char]::IsControl($character)) { + throw "Package contains an unsafe ZIP entry name: '$Name'." + } + } + + $canonicalName = $Name + if ($canonicalName.EndsWith('/', [System.StringComparison]::Ordinal)) { + $canonicalName = $canonicalName.Substring(0, $canonicalName.Length - 1) + } + if ([string]::IsNullOrEmpty($canonicalName)) { + throw "Package contains an unsafe ZIP entry name: '$Name'." + } + + $segments = $canonicalName.Split([char[]]@('/'), [System.StringSplitOptions]::None) + foreach ($segment in $segments) { + $hasUnsafeCharacter = $segment.IndexOfAny([char[]]@('<', '>', ':', '"', '|', '?', '*')) -ge 0 + $baseName = $segment.Split('.', 2)[0] + $isUnsafeSegment = [string]::IsNullOrEmpty($segment) -or + $segment -eq '.' -or + $segment -eq '..' -or + $segment.Length -gt 255 -or + $segment.EndsWith('.', [System.StringComparison]::Ordinal) -or + $segment.EndsWith(' ', [System.StringComparison]::Ordinal) -or + $hasUnsafeCharacter -or + $baseName -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$' + if ($isUnsafeSegment) { + throw "Package contains an unsafe ZIP entry name: '$Name'." + } + } +} + +function Get-ObserverZipManifest { + param([Parameter(Mandatory)][string] $ArchivePath) + + $resolvedArchivePath = [System.IO.Path]::GetFullPath($ArchivePath) + if (-not (Test-Path -LiteralPath $resolvedArchivePath -PathType Leaf)) { + throw "Package archive does not exist: $resolvedArchivePath" + } + + $fileNames = [System.Collections.Generic.List[string]]::new() + $directoryNames = [System.Collections.Generic.List[string]]::new() + $entryNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $fileStream = [System.IO.File]::Open( + $resolvedArchivePath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + try { + $archive = [System.IO.Compression.ZipArchive]::new( + $fileStream, + [System.IO.Compression.ZipArchiveMode]::Read, + $true + ) + try { + foreach ($entry in $archive.Entries) { + $entryName = $entry.FullName + Assert-SafeObserverZipEntryName -Name $entryName + + $isDirectory = $entryName.EndsWith('/', [System.StringComparison]::Ordinal) + $collisionKey = if ($isDirectory) { + $entryName.Substring(0, $entryName.Length - 1) + } + else { + $entryName + } + if (-not $entryNames.Add($collisionKey)) { + throw "Package contains a duplicate or case-colliding ZIP entry: '$entryName'." + } + + if ($isDirectory) { + if ($entry.Length -ne 0) { + throw "Package contains an unsafe ZIP entry with directory data: '$entryName'." + } + $directoryNames.Add($collisionKey) + } + else { + $fileNames.Add($entryName) + } + } + } + finally { + $archive.Dispose() + } + } + finally { + $fileStream.Dispose() + } + + return [pscustomobject]@{ + ArchivePath = $resolvedArchivePath + Files = $fileNames.ToArray() + Directories = $directoryNames.ToArray() + } +} + +function Get-ObserverExpectedDirectoryName { + param([Parameter(Mandatory)][string[]] $FileName) + + $directories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($name in $FileName) { + $separatorIndex = $name.LastIndexOf('/', [System.StringComparison]::Ordinal) + while ($separatorIndex -gt 0) { + $null = $directories.Add($name.Substring(0, $separatorIndex)) + $separatorIndex = $name.LastIndexOf('/', $separatorIndex - 1) + } + } + return ,$directories +} + +function Assert-ObserverPackageEntrySet { + param( + [Parameter(Mandatory)][string] $ArchivePath, + [Parameter(Mandatory)][string[]] $ExpectedFileName, + [Parameter(Mandatory)][string] $PackageDescription + ) + + $manifest = Get-ObserverZipManifest -ArchivePath $ArchivePath + $expectedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($name in $ExpectedFileName) { + $null = $expectedFiles.Add($name) + } + $actualFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($name in @($manifest.Files)) { + $null = $actualFiles.Add($name) + } + $expectedDirectories = Get-ObserverExpectedDirectoryName -FileName $ExpectedFileName + + $missingFiles = @($ExpectedFileName | Where-Object { -not $actualFiles.Contains($_) } | Sort-Object) + $extraFiles = @($manifest.Files | Where-Object { -not $expectedFiles.Contains($_) } | Sort-Object) + $extraDirectories = @( + $manifest.Directories | + Where-Object { -not $expectedDirectories.Contains($_) } | + Sort-Object + ) + if ($missingFiles.Count -ne 0 -or $extraFiles.Count -ne 0 -or $extraDirectories.Count -ne 0) { + $details = @( + if ($missingFiles.Count -ne 0) { "missing: $($missingFiles -join ', ')" } + if ($extraFiles.Count -ne 0) { "extra files: $($extraFiles -join ', ')" } + if ($extraDirectories.Count -ne 0) { "extra directories: $($extraDirectories -join ', ')" } + ) -join '; ' + throw "$PackageDescription manifest does not match the exact allowlist ($details)." + } + + return $manifest +} + +function Assert-ObserverModulePackageManifest { + param( + [Parameter(Mandatory)][string] $ArchivePath, + [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName + ) + + return Assert-ObserverPackageEntrySet ` + -ArchivePath $ArchivePath ` + -ExpectedFileName $script:ObserverModulePackageEntries[$ModuleName] ` + -PackageDescription "$ModuleName module package" +} + +function Assert-ObserverSymbolsPackageManifest { + param([Parameter(Mandatory)][string] $ArchivePath) + + return Assert-ObserverPackageEntrySet ` + -ArchivePath $ArchivePath ` + -ExpectedFileName $script:ObserverSymbolsPackageEntries ` + -PackageDescription 'Combined symbols package' +} diff --git a/build/lib/package-smoke.ps1 b/build/lib/package-smoke.ps1 new file mode 100644 index 0000000..8d7bdaa --- /dev/null +++ b/build/lib/package-smoke.ps1 @@ -0,0 +1,177 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Get-ObserverExtractedPackageManifest { + param([Parameter(Mandatory)][string] $DirectoryPath) + + $root = [System.IO.Path]::GetFullPath($DirectoryPath) + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw "Extracted package directory does not exist: $root" + } + + $files = [System.Collections.Generic.List[string]]::new() + $directories = [System.Collections.Generic.List[string]]::new() + foreach ($item in Get-ChildItem -LiteralPath $root -Force -Recurse) { + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Extracted package contains a reparse point: $($item.FullName)" + } + + $relativeName = [System.IO.Path]::GetRelativePath($root, $item.FullName).Replace('\', '/') + Assert-SafeObserverZipEntryName -Name $relativeName + if ($item.PSIsContainer) { + $directories.Add($relativeName) + } + else { + $files.Add($relativeName) + } + } + + return [pscustomobject]@{ + DirectoryPath = $root + Files = $files.ToArray() + Directories = $directories.ToArray() + } +} + +function Assert-ObserverExtractedPackageEntrySet { + param( + [Parameter(Mandatory)][string] $DirectoryPath, + [Parameter(Mandatory)][string[]] $ExpectedFileName, + [Parameter(Mandatory)][string] $PackageDescription + ) + + $manifest = Get-ObserverExtractedPackageManifest -DirectoryPath $DirectoryPath + $expectedDirectories = Get-ObserverExpectedDirectoryName -FileName $ExpectedFileName + $actualFiles = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$manifest.Files, + [System.StringComparer]::Ordinal + ) + $actualDirectories = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$manifest.Directories, + [System.StringComparer]::Ordinal + ) + + $missingFiles = @($ExpectedFileName | Where-Object { -not $actualFiles.Contains($_) } | Sort-Object) + $extraFiles = @($manifest.Files | Where-Object { $_ -notin $ExpectedFileName } | Sort-Object) + $missingDirectories = @($expectedDirectories | Where-Object { -not $actualDirectories.Contains($_) } | Sort-Object) + $extraDirectories = @($manifest.Directories | Where-Object { -not $expectedDirectories.Contains($_) } | Sort-Object) + if ( + $missingFiles.Count -ne 0 -or + $extraFiles.Count -ne 0 -or + $missingDirectories.Count -ne 0 -or + $extraDirectories.Count -ne 0 + ) { + $details = @( + if ($missingFiles.Count -ne 0) { "missing files: $($missingFiles -join ', ')" } + if ($extraFiles.Count -ne 0) { "extra files: $($extraFiles -join ', ')" } + if ($missingDirectories.Count -ne 0) { "missing directories: $($missingDirectories -join ', ')" } + if ($extraDirectories.Count -ne 0) { "extra directories: $($extraDirectories -join ', ')" } + ) -join '; ' + throw "$PackageDescription extracted manifest does not match the exact allowlist ($details)." + } + + return $manifest +} + +function Assert-ObserverExtractedModulePackage { + param( + [Parameter(Mandatory)][string] $DirectoryPath, + [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName + ) + + return Assert-ObserverExtractedPackageEntrySet ` + -DirectoryPath $DirectoryPath ` + -ExpectedFileName $script:ObserverModulePackageEntries[$ModuleName] ` + -PackageDescription "$ModuleName module package" +} + +function Assert-ObserverExtractedSymbolsPackage { + param([Parameter(Mandatory)][string] $DirectoryPath) + + return Assert-ObserverExtractedPackageEntrySet ` + -DirectoryPath $DirectoryPath ` + -ExpectedFileName $script:ObserverSymbolsPackageEntries ` + -PackageDescription 'Combined symbols package' +} + +function Expand-ObserverModulePackageForSmoke { + param( + [Parameter(Mandatory)][string] $ArchivePath, + [Parameter(Mandatory)][string] $DestinationPath, + [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName + ) + + $destination = [System.IO.Path]::GetFullPath($DestinationPath) + if (Test-Path -LiteralPath $destination) { + throw "Package smoke destination already exists: $destination" + } + + [void](Assert-ObserverModulePackageManifest -ArchivePath $ArchivePath -ModuleName $ModuleName) + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $destination + [void](Assert-ObserverExtractedModulePackage -DirectoryPath $destination -ModuleName $ModuleName) + return $destination +} + +function Expand-ObserverSymbolsPackageForSmoke { + param( + [Parameter(Mandatory)][string] $ArchivePath, + [Parameter(Mandatory)][string] $DestinationPath + ) + + $destination = [System.IO.Path]::GetFullPath($DestinationPath) + if (Test-Path -LiteralPath $destination) { + throw "Package smoke destination already exists: $destination" + } + + [void](Assert-ObserverSymbolsPackageManifest -ArchivePath $ArchivePath) + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $destination + [void](Assert-ObserverExtractedSymbolsPackage -DirectoryPath $destination) + return $destination +} + +function Invoke-ObserverPackageRuntimeSmoke { + param( + [Parameter(Mandatory)][string] $TestExecutablePath, + [Parameter(Mandatory)][string] $ModulePath, + [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName, + [Parameter(Mandatory)][string] $WorkingDirectory, + [Parameter()][string] $ReportPath + ) + + if (-not [System.IO.Path]::IsPathFullyQualified($ModulePath)) { + throw "Package module path must be absolute: $ModulePath" + } + $resolvedModulePath = [System.IO.Path]::GetFullPath($ModulePath) + if (-not (Test-Path -LiteralPath $resolvedModulePath -PathType Leaf)) { + throw "Extracted package module does not exist: $resolvedModulePath" + } + $resolvedTestExecutable = [System.IO.Path]::GetFullPath($TestExecutablePath) + if (-not (Test-Path -LiteralPath $resolvedTestExecutable -PathType Leaf)) { + throw "Package smoke test executable does not exist: $resolvedTestExecutable" + } + + $arguments = [System.Collections.Generic.List[string]]::new() + foreach ($argument in @('[package-smoke]', '--reporter', 'compact', '--rng-seed', '1')) { + $arguments.Add($argument) + } + if ($ReportPath) { + $arguments.Add('--reporter') + $arguments.Add("JUnit::out=$([System.IO.Path]::GetFullPath($ReportPath))") + } + + $previousModule = [Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', 'Process') + $previousFormat = [Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', 'Process') + try { + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $resolvedModulePath, 'Process') + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $ModuleName, 'Process') + Invoke-Native ` + -FilePath $resolvedTestExecutable ` + -Arguments $arguments.ToArray() ` + -WorkingDirectory $WorkingDirectory + } + finally { + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $previousModule, 'Process') + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $previousFormat, 'Process') + } +} diff --git a/build/lib/packaging.ps1 b/build/lib/packaging.ps1 new file mode 100644 index 0000000..a34aefe --- /dev/null +++ b/build/lib/packaging.ps1 @@ -0,0 +1,118 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Invoke-Package { + param([Parameter(Mandatory)][string[]] $Architectures) + + Invoke-Restore -Architectures $Architectures + Invoke-Build -Architectures $Architectures -Configuration 'Release' + Invoke-Audit -Architectures $Architectures + + $date = Get-Date -Format 'yyyy-MM-dd' + $packagesDirectory = Join-Path $script:ArtifactsRoot 'packages' + $moduleLicenseFiles = @{ + renpy = @('Observer.txt', 'rpatool.txt', 'serde-pickle.txt', 'zlib.txt') + rpgmaker = @('Observer.txt', 'rgssad.txt') + zanzarah = @('Observer.txt', 'zanzapak.txt') + } + $packageSmokeRunRoot = Join-Path $script:ArtifactsRoot "package-smoke\$([guid]::NewGuid().ToString('N'))" + $packageSmokeReportDirectory = Join-Path $script:ArtifactsRoot 'reports\package-smoke' + $packageEvidence = [System.Collections.Generic.List[object]]::new() + New-Item -ItemType Directory -Force -Path $packageSmokeReportDirectory | Out-Null + if (Test-Path -LiteralPath $packagesDirectory) { + Remove-Item -Recurse -Force -LiteralPath $packagesDirectory + } + New-Item -ItemType Directory -Force -Path $packagesDirectory | Out-Null + + foreach ($architecture in $Architectures) { + $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Release' + $architectureSymbols = Join-Path $script:ArtifactsRoot "package\symbols\$architecture" + if (Test-Path -LiteralPath $architectureSymbols) { + Remove-Item -Recurse -Force -LiteralPath $architectureSymbols + } + New-Item -ItemType Directory -Force -Path $architectureSymbols | Out-Null + foreach ($moduleName in $script:ModuleNames) { + $stage = Join-Path $script:ArtifactsRoot "package\$architecture\$moduleName" + if (Test-Path -LiteralPath $stage) { + Remove-Item -Recurse -Force -LiteralPath $stage + } + $docsDirectory = Join-Path $stage 'docs' + $thirdPartyDirectory = Join-Path $docsDirectory 'thirdparty' + New-Item -ItemType Directory -Force -Path $thirdPartyDirectory | Out-Null + Copy-Item -LiteralPath (Join-Path $binaryDirectory "$moduleName.so") -Destination $stage + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot "src\modules\$moduleName\observer_user.ini") -Destination $stage + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot 'LICENSE.txt') -Destination (Join-Path $docsDirectory 'license.txt') + foreach ($licenseFile in $moduleLicenseFiles[$moduleName]) { + $licensePath = Join-Path $script:RepositoryRoot "licenses\$licenseFile" + if (-not (Test-Path -LiteralPath $licensePath -PathType Leaf)) { + throw "Required third-party license is missing for ${moduleName}: $licensePath" + } + Copy-Item -LiteralPath $licensePath -Destination $thirdPartyDirectory + } + + $moduleArchive = Join-Path $packagesDirectory "$moduleName-$date-$architecture-dll.zip" + if (Test-Path -LiteralPath $moduleArchive) { + Remove-Item -Force -LiteralPath $moduleArchive + } + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $moduleArchive -CompressionLevel Optimal + [void](Assert-ObserverModulePackageManifest -ArchivePath $moduleArchive -ModuleName $moduleName) + $extractedPackage = Expand-ObserverModulePackageForSmoke ` + -ArchivePath $moduleArchive ` + -DestinationPath (Join-Path $packageSmokeRunRoot "$architecture\$moduleName") ` + -ModuleName $moduleName + + $runtimeResult = 'deferred' + if (Test-CanRunArchitecture -Architecture $architecture) { + Write-Step "Running package smoke for $moduleName $architecture" + $smokeReport = Join-Path $packageSmokeReportDirectory "$moduleName-$architecture-release.xml" + Invoke-ObserverPackageRuntimeSmoke ` + -TestExecutablePath (Join-Path $binaryDirectory 'tests.exe') ` + -ModulePath (Join-Path $extractedPackage "$moduleName.so") ` + -ModuleName $moduleName ` + -WorkingDirectory $extractedPackage ` + -ReportPath $smokeReport + $runtimeResult = 'passed' + } + else { + Write-Host "Deferred package runtime smoke for $moduleName ${architecture}: the current host cannot load $architecture binaries." + } + + $packageEvidence.Add([pscustomobject]@{ + Kind = 'module' + Architecture = $architecture + Module = $moduleName + Archive = [System.IO.Path]::GetFullPath($moduleArchive) + SHA256 = (Get-FileHash -LiteralPath $moduleArchive -Algorithm SHA256).Hash + ExtractedDirectory = $extractedPackage + RuntimeSmoke = $runtimeResult + }) + Copy-Item -LiteralPath (Join-Path $binaryDirectory "$moduleName.pdb") -Destination $architectureSymbols + Write-Host "Created and validated $moduleArchive" + } + + $symbolsArchive = Join-Path $packagesDirectory "observer-modules-$date-$architecture-pdb.zip" + if (Test-Path -LiteralPath $symbolsArchive) { + Remove-Item -Force -LiteralPath $symbolsArchive + } + Compress-Archive -Path (Join-Path $architectureSymbols '*') -DestinationPath $symbolsArchive -CompressionLevel Optimal + [void](Assert-ObserverSymbolsPackageManifest -ArchivePath $symbolsArchive) + $extractedSymbols = Expand-ObserverSymbolsPackageForSmoke ` + -ArchivePath $symbolsArchive ` + -DestinationPath (Join-Path $packageSmokeRunRoot "$architecture\symbols") + $packageEvidence.Add([pscustomobject]@{ + Kind = 'symbols' + Architecture = $architecture + Module = $null + Archive = [System.IO.Path]::GetFullPath($symbolsArchive) + SHA256 = (Get-FileHash -LiteralPath $symbolsArchive -Algorithm SHA256).Hash + ExtractedDirectory = $extractedSymbols + RuntimeSmoke = 'not-applicable' + }) + Write-Host "Created and validated $symbolsArchive" + } + + $evidencePath = Join-Path $packagesDirectory 'package-smoke-evidence.json' + $packageEvidence | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $evidencePath -Encoding utf8NoBOM + Write-Host "Package smoke evidence: $evidencePath" +} diff --git a/build/lib/verify-routing.ps1 b/build/lib/verify-routing.ps1 new file mode 100644 index 0000000..da55aff --- /dev/null +++ b/build/lib/verify-routing.ps1 @@ -0,0 +1,95 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Get-CurrentVerifyHostArchitecture { + $hostArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + if ($hostArchitecture -notin @('x86', 'x64', 'arm64')) { + throw "The current host architecture is unsupported: $hostArchitecture" + } + return $hostArchitecture +} + +function Test-VerifyArchitectureRunnable { + param( + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $HostArchitecture, + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $TargetArchitecture + ) + + switch ($HostArchitecture) { + 'x86' { return $TargetArchitecture -eq 'x86' } + 'x64' { return $TargetArchitecture -in @('x86', 'x64') } + 'arm64' { return $TargetArchitecture -in @('x86', 'x64', 'arm64') } + } +} + +function Get-VerifyRoutingPlan { + param( + [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $HostArchitecture, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $RequestedArchitectures + ) + + $knownArchitectures = @('x86', 'x64', 'arm64') + $requested = @($RequestedArchitectures | Select-Object -Unique) + $unsupported = @($requested | Where-Object { $_ -notin $knownArchitectures }) + if ($unsupported.Count -ne 0) { + throw "Unsupported verify architecture(s): $($unsupported -join ', ')" + } + + $builds = [System.Collections.Generic.List[object]]::new() + $testRuns = [System.Collections.Generic.List[object]]::new() + $specialistGates = [System.Collections.Generic.List[object]]::new() + $packageRuntimeArchitectures = [System.Collections.Generic.List[string]]::new() + $deferred = [System.Collections.Generic.List[object]]::new() + + foreach ($architecture in $requested) { + foreach ($configuration in @('Debug', 'Release')) { + $builds.Add([pscustomobject]@{ + Architecture = $architecture + Configuration = $configuration + }) + if (Test-VerifyArchitectureRunnable -HostArchitecture $HostArchitecture -TargetArchitecture $architecture) { + $testRuns.Add([pscustomobject]@{ + Architecture = $architecture + Configuration = $configuration + }) + } + } + + if (Test-VerifyArchitectureRunnable -HostArchitecture $HostArchitecture -TargetArchitecture $architecture) { + $packageRuntimeArchitectures.Add($architecture) + } else { + $deferred.Add([pscustomobject]@{ + Gate = 'tests' + Architecture = $architecture + Reason = "The $HostArchitecture host cannot execute $architecture test binaries; use a native $architecture runner." + }) + $deferred.Add([pscustomobject]@{ + Gate = 'package-runtime' + Architecture = $architecture + Reason = "The $HostArchitecture host can validate $architecture package contents but cannot load its DLL." + }) + } + } + + if ('x64' -in $requested) { + foreach ($name in @('coverage', 'ubsan', 'leaks', 'formats')) { + $specialistGates.Add([pscustomobject]@{ Name = $name; Architecture = 'x64' }) + } + } + foreach ($architecture in @('x86', 'x64')) { + if ($architecture -in $requested) { + $specialistGates.Add([pscustomobject]@{ Name = 'asan'; Architecture = $architecture }) + } + } + + return [pscustomobject]@{ + RequestedArchitectures = @($requested) + Builds = @($builds) + TestRuns = @($testRuns) + SpecialistGates = @($specialistGates) + PackageContentArchitectures = @($requested) + PackageRuntimeArchitectures = @($packageRuntimeArchitectures) + Deferred = @($deferred) + } +} diff --git a/build/lib/verify.ps1 b/build/lib/verify.ps1 new file mode 100644 index 0000000..3aa1c27 --- /dev/null +++ b/build/lib/verify.ps1 @@ -0,0 +1,75 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest + +function Invoke-Verify { + param( + [Parameter(Mandatory)][string[]] $Architectures, + [Parameter()][string] $CorpusPath, + [Parameter(Mandatory)][double] $RequiredCoverageThreshold, + [Parameter(Mandatory)][int] $RequiredFuzzSeconds, + [Parameter(Mandatory)][int] $RequiredLeakWarmup, + [Parameter(Mandatory)][int] $RequiredLeakIterations, + [Parameter(Mandatory)][int] $RequiredLeakWindows, + [Parameter(Mandatory)][int64] $RequiredLeakToleranceBytes + ) + + $plan = Get-VerifyRoutingPlan ` + -HostArchitecture (Get-CurrentVerifyHostArchitecture) ` + -RequestedArchitectures $Architectures + + Invoke-Restore -Architectures $plan.RequestedArchitectures + Invoke-Lint -Architectures $plan.RequestedArchitectures + + foreach ($buildGroup in @($plan.Builds | Group-Object Configuration)) { + $buildArchitectures = @($buildGroup.Group | ForEach-Object Architecture) + Invoke-Build -Architectures $buildArchitectures -Configuration $buildGroup.Name + } + foreach ($testRun in @($plan.TestRuns)) { + Invoke-TestExecutable ` + -Architecture $testRun.Architecture ` + -Configuration $testRun.Configuration ` + -CorpusPath $CorpusPath + } + + Invoke-CodeAnalysis -Architectures $plan.RequestedArchitectures + + foreach ($gateGroup in @($plan.SpecialistGates | Group-Object Name)) { + $gateArchitectures = @($gateGroup.Group | ForEach-Object Architecture) + switch ($gateGroup.Name) { + 'coverage' { + Invoke-Coverage ` + -Architectures $gateArchitectures ` + -CorpusPath $CorpusPath ` + -Threshold $RequiredCoverageThreshold + } + 'asan' { Invoke-ASan -Architectures $gateArchitectures } + 'ubsan' { Invoke-Ubsan -Architectures $gateArchitectures } + 'leaks' { + Invoke-LeakTest ` + -Architectures $gateArchitectures ` + -Warmup $RequiredLeakWarmup ` + -Iterations $RequiredLeakIterations ` + -Windows $RequiredLeakWindows ` + -ToleranceBytes $RequiredLeakToleranceBytes + } + 'formats' { + Invoke-Fuzz ` + -Architectures $gateArchitectures ` + -Seconds $RequiredFuzzSeconds ` + -TargetName 'all' + } + default { throw "Unknown verify specialist gate: $($gateGroup.Name)" } + } + } + + Invoke-Package -Architectures $plan.PackageContentArchitectures + + if ($plan.Deferred.Count -ne 0) { + Write-Step 'Runtime checks deferred to native runners' + foreach ($deferred in @($plan.Deferred)) { + Write-Host "[DEFERRED] $($deferred.Gate) $($deferred.Architecture): $($deferred.Reason)" + } + } + Write-Host "[OK] Verify completed every host-capable gate; deferred native checks: $($plan.Deferred.Count)." +} diff --git a/build/mutation/README.md b/build/mutation/README.md new file mode 100644 index 0000000..1568209 --- /dev/null +++ b/build/mutation/README.md @@ -0,0 +1,36 @@ +# Portable mutation gate + +This directory contains the Linux-only mutation-test slice for the already portable +Ren'Py Pickle parser. It is intentionally outside the shipped build graph: + +- release DLLs remain MSVC/MSBuild-built Windows binaries; +- repository CMake is not introduced; +- `run.sh` compiles only `pickle.cpp`, its existing Catch2 unit tests, and the + platform-neutral test entry point; +- Mull is filtered to the first-party `pickle.cpp` implementation, so Catch2, + test code, and vcpkg sources are not mutation targets. + +The CI workflow installs exact Mull `0.34.0` for LLVM 19 from its signed repository, +checks the repository-key fingerprint before installation, and checks out vcpkg at +the repository manifest baseline. Local tool installation is not performed by any +repository command and still requires owner approval. + +Mull runs in strict mode with a mutation-score threshold of 100. The separate +`validate_report.py` gate also rejects an empty report and accepts only `Killed` +statuses. This closes the otherwise misleading case where a run with no discovered +mutants can report an infinite score. Mutation Testing Elements JSON and the full +CI log are retained as workflow artifacts before the job enforces failure. + +The Windows development host can validate the report policy and static workflow +contract without Mull: + +```powershell +uv run --no-python-downloads --offline --no-config --python 3.14.6 ` + python -m unittest build.tests.test_mutation_report -v +pwsh -NoProfile -File build/tests/mutation-ci-contract.Tests.ps1 +``` + +The first GitHub run remains the execution proof for the Ubuntu package repository, +Clang/Mull plugin ABI, direct Catch2 link, and actual surviving-mutant inventory. +Any surviving non-equivalent mutant is a test defect. Equivalent mutants require +explicit owner-reviewed disposition; they are not hidden by weakening the gate. diff --git a/build/mutation/mull.yml b/build/mutation/mull.yml new file mode 100644 index 0000000..5d9b4aa --- /dev/null +++ b/build/mutation/mull.yml @@ -0,0 +1,17 @@ +quiet: false +silent: false +strict: true +includeNotCovered: false +dryRunEnabled: false +captureTestOutput: false +captureMutantOutput: false +mutators: + - cxx_default +includePaths: + - '.*/src/modules/renpy/pickle\.cpp$' +excludePaths: + - '.*/\.artifacts/.*' + - '.*/src/tests/.*' +parallelization: + workers: 2 + executionWorkers: 2 diff --git a/build/mutation/run.sh b/build/mutation/run.sh new file mode 100644 index 0000000..1913b28 --- /dev/null +++ b/build/mutation/run.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repository_root="$(cd -- "${script_directory}/../.." && pwd -P)" +artifact_root="${repository_root}/.artifacts" +object_directory="${artifact_root}/mutation/obj" +report_directory="${artifact_root}/reports/mutation" +test_binary="${artifact_root}/mutation/pickle-tests" + +: "${MULL_LLVM:?MULL_LLVM must select the installed Clang and Mull major version}" +: "${VCPKG_INSTALLED_DIR:?VCPKG_INSTALLED_DIR must point to the pinned vcpkg install root}" + +compiler="clang++-${MULL_LLVM}" +mull_runner="mull-runner-${MULL_LLVM}" +mull_plugin="/usr/lib/mull-ir-frontend-${MULL_LLVM}" +catch_include="${VCPKG_INSTALLED_DIR}/x64-linux/include" +catch_library="${VCPKG_INSTALLED_DIR}/x64-linux/lib" +report_path="${report_directory}/pickle.json" + +for executable in "${compiler}" "${mull_runner}" python3; do + command -v "${executable}" >/dev/null +done +for required_file in \ + "${mull_plugin}" \ + "${catch_include}/catch2/catch_session.hpp" \ + "${catch_library}/libCatch2.a"; do + test -f "${required_file}" +done + +mkdir -p -- "${object_directory}" "${report_directory}" + +common_flags=( + -std=c++23 + -O0 + -g + -fno-omit-frame-pointer + -Wall + -Wextra + -Wpedantic + -Werror + -isystem "${catch_include}" +) + +export MULL_CONFIG="${repository_root}/build/mutation/mull.yml" + +"${compiler}" "${common_flags[@]}" \ + "-fpass-plugin=/usr/lib/mull-ir-frontend-${MULL_LLVM}" \ + -c "${repository_root}/src/modules/renpy/pickle.cpp" \ + -o "${object_directory}/pickle.o" +"${compiler}" "${common_flags[@]}" \ + -c "${repository_root}/src/tests/unit/pickle.cpp" \ + -o "${object_directory}/pickle-tests.o" +"${compiler}" "${common_flags[@]}" \ + -c "${repository_root}/src/tests/mutation/main.cpp" \ + -o "${object_directory}/main.o" +"${compiler}" \ + "${object_directory}/pickle.o" \ + "${object_directory}/pickle-tests.o" \ + "${object_directory}/main.o" \ + -L "${catch_library}" \ + -lCatch2 \ + -pthread \ + -o "${test_binary}" + +"${test_binary}" + +"${mull_runner}" \ + --workers 2 \ + --strict \ + --mutation-score-threshold 100 \ + --no-output \ + --reporters Elements \ + --report-dir "${report_directory}" \ + --report-name pickle \ + "${test_binary}" \ + 2>&1 | tee "${report_directory}/mull.log" + +python3 "${repository_root}/build/mutation/validate_report.py" "${report_path}" diff --git a/build/mutation/validate_report.py b/build/mutation/validate_report.py new file mode 100644 index 0000000..364cd97 --- /dev/null +++ b/build/mutation/validate_report.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Fail unless a Mutation Testing Elements report is non-empty and fully killed.""" + +import argparse +import json +import sys +from pathlib import Path + + +class ReportError(ValueError): + """The mutation report cannot prove the repository gate.""" + + +def _mutant_identity(filename, mutant): + mutant_id = mutant.get("id", "") + location = mutant.get("location", {}) + start = location.get("start", {}) if isinstance(location, dict) else {} + line = start.get("line") if isinstance(start, dict) else None + source = f"{filename}:{line}" if isinstance(line, int) else filename + return f"{source} [{mutant_id}]" + + +def validate(report): + if not isinstance(report, dict): + raise ReportError("report root must be a JSON object") + + files = report.get("files") + if not isinstance(files, dict): + raise ReportError("report field 'files' must be an object") + + reached = [] + for filename, file_result in files.items(): + if not isinstance(filename, str) or not isinstance(file_result, dict): + raise ReportError("every report file entry must have a string name and object value") + mutants = file_result.get("mutants") + if not isinstance(mutants, list): + raise ReportError(f"report file '{filename}' must contain a mutants array") + for mutant in mutants: + if not isinstance(mutant, dict): + raise ReportError(f"report file '{filename}' contains a non-object mutant") + status = mutant.get("status") + if not isinstance(status, str) or not status: + raise ReportError(f"mutant {_mutant_identity(filename, mutant)} has no valid status") + reached.append((filename, mutant, status)) + + if not reached: + raise ReportError("report contains no reached mutants") + + failures = [item for item in reached if item[2] != "Killed"] + if failures: + details = ", ".join( + f"{status}: {_mutant_identity(filename, mutant)}" + for filename, mutant, status in failures[:20] + ) + remainder = len(failures) - 20 + if remainder: + details += f", and {remainder} more" + raise ReportError(f"{len(failures)} reached mutants were not killed: {details}") + + return len(reached) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", type=Path, help="Mutation Testing Elements JSON report") + arguments = parser.parse_args(argv) + + try: + with arguments.report.open(encoding="utf-8") as stream: + report = json.load(stream) + except (OSError, json.JSONDecodeError) as error: + print(f"mutation report is not valid JSON: {error}", file=sys.stderr) + return 1 + + try: + count = validate(report) + except ReportError as error: + print(f"mutation gate failed: {error}", file=sys.stderr) + return 1 + + print(f"mutation report: {count} reached mutants, all killed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/native_graph.py b/build/native_graph.py new file mode 100644 index 0000000..42abb23 --- /dev/null +++ b/build/native_graph.py @@ -0,0 +1,458 @@ +"""Typed expansion for the fine-grained Windows native and analysis DAG. + +The module only describes normalized graph nodes. Process execution remains in the +outer graph driver, and all compile/link work remains in the checked-in MSBuild +projects. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +from pathlib import Path, PurePosixPath +import re +import xml.etree.ElementTree as ET + + +MSBUILD_NAMESPACE = "http://schemas.microsoft.com/developer/msbuild/2003" +PROJECT_NAMES = ( + "renpy", + "rpgmaker", + "zanzarah", + "tests", + "fuzz-pickle", + "fuzz-renpy", + "fuzz-rpgmaker", + "fuzz-zanzarah", + "leak-probe", +) +RUNTIME_PROJECT_NAMES = ("renpy", "rpgmaker", "zanzarah", "tests") +FUZZ_PROJECT_NAMES = ("fuzz-pickle", "fuzz-renpy", "fuzz-rpgmaker", "fuzz-zanzarah") +FUZZ_TARGET_NAMES = tuple(name.removeprefix("fuzz-") for name in FUZZ_PROJECT_NAMES) +RUNTIME_CONFIGURATIONS = ("Debug", "Release", "Coverage", "ASan", "UBSan") + + +class NativeGraphError(ValueError): + """Raised when the checked-in native project manifest is not reviewable.""" + + +@dataclass(frozen=True) +class TranslationUnit: + project: str + source: PurePosixPath + slug: str + + @property + def key(self) -> str: + return f"{self.project}:{self.source.as_posix()}" + + +@dataclass(frozen=True) +class Project: + name: str + project_file: PurePosixPath + translation_units: tuple[TranslationUnit, ...] + + +@dataclass(frozen=True) +class Manifest: + workspace: Path + projects: tuple[Project, ...] + + @property + def translation_units(self) -> tuple[TranslationUnit, ...]: + return tuple(unit for project in self.projects for unit in project.translation_units) + + +def _translation_unit_slug(source: PurePosixPath) -> str: + readable = source.as_posix().removeprefix("src/").removesuffix(".cpp") + readable = re.sub(r"[^a-z0-9]+", "-", readable.lower()).strip("-") + digest = sha256(source.as_posix().encode("utf-8")).hexdigest()[:8] + return f"{readable}-{digest}" + + +def _repository_source(include: str, workspace: Path, project_name: str) -> PurePosixPath: + prefix = "$(RepositoryRoot)" + if not include.startswith(prefix): + raise NativeGraphError( + f"{project_name} ClCompile path must start with {prefix!r}: {include!r}" + ) + source = PurePosixPath(include.removeprefix(prefix).replace("\\", "/")) + if source.is_absolute() or ".." in source.parts or source.suffix.lower() != ".cpp": + raise NativeGraphError(f"unsafe {project_name} translation unit path: {source}") + if not (workspace / Path(*source.parts)).is_file(): + raise NativeGraphError(f"missing {project_name} translation unit: {source}") + return source + + +def load_manifest( + workspace: Path, *, project_names: tuple[str, ...] = PROJECT_NAMES +) -> Manifest: + resolved_workspace = workspace.resolve() + unknown = [name for name in project_names if name not in PROJECT_NAMES] + if unknown: + raise NativeGraphError(f"unknown project(s): {', '.join(unknown)}") + if len(set(project_names)) != len(project_names): + raise NativeGraphError("project manifest contains duplicate names") + + projects: list[Project] = [] + for project_name in project_names: + project_path = resolved_workspace / "build" / "projects" / f"{project_name}.vcxproj" + if not project_path.is_file(): + raise NativeGraphError(f"missing project: {project_path}") + try: + root = ET.parse(project_path).getroot() + except ET.ParseError as error: + raise NativeGraphError(f"invalid MSBuild XML in {project_path}: {error}") from error + + sources = tuple( + _repository_source(element.attrib["Include"], resolved_workspace, project_name) + for element in root.findall(f".//{{{MSBUILD_NAMESPACE}}}ClCompile") + if "Include" in element.attrib + ) + if not sources: + raise NativeGraphError(f"project has no ClCompile items: {project_name}") + if len(set(sources)) != len(sources): + raise NativeGraphError(f"project has duplicate ClCompile items: {project_name}") + units = tuple( + TranslationUnit(project_name, source, _translation_unit_slug(source)) + for source in sources + ) + projects.append( + Project( + project_name, + PurePosixPath("build", "projects", f"{project_name}.vcxproj"), + units, + ) + ) + return Manifest(resolved_workspace, tuple(projects)) + + +def _leaf_argv(action: str, *arguments: str) -> list[str]: + return [ + "pwsh", + "-NoLogo", + "-NoProfile", + "-File", + "build.ps1", + "graph-leaf", + "-GraphLeafAction", + action, + *arguments, + ] + + +def fuzz_build_node_name(target: str) -> str: + """Return the single build-node identity shared with the dynamic fuzz graph.""" + + if target not in FUZZ_TARGET_NAMES: + raise NativeGraphError(f"unknown fuzz target: {target}") + return f"build-fuzz-{target}" + + +def build_project_node_name(configuration: str, project_name: str) -> str: + """Return the build-node identity shared by native and dynamic graph sections.""" + + supported = ( + project_name in RUNTIME_PROJECT_NAMES + and configuration in RUNTIME_CONFIGURATIONS + ) or (project_name == "leak-probe" and configuration == "Release") + if not supported: + raise NativeGraphError( + f"unsupported project build: {configuration}/{project_name}" + ) + return f"build-{configuration.lower()}-{project_name}" + + +def _node( + name: str, + *, + deps: list[str], + resources: dict[str, int], + argv: list[str], + inputs: list[str], + outputs: list[str], + writes: list[str], +) -> dict[str, object]: + return { + "name": name, + "deps": deps, + "run_after": [], + "resources": resources, + "argv": argv, + "inputs": inputs, + "outputs": outputs, + "writes": writes, + "fingerprint": ["contract=native-microdag-v1", f"node={name}"], + "cacheable": False, + } + + +def _binary_writes(configuration: str, project_name: str) -> list[str]: + root = f".artifacts/bin/x64/{configuration}" + object_root = f".artifacts/obj/x64/{configuration}/{project_name}/" + if project_name in ("tests", "leak-probe") or project_name.startswith("fuzz-"): + return [object_root, f"{root}/{project_name}.exe", f"{root}/{project_name}.pdb"] + return [ + object_root, + f"{root}/{project_name}.so", + f"{root}/{project_name}.pdb", + f"{root}/{project_name}.lib", + f"{root}/{project_name}.exp", + ] + + +def _build_node(configuration: str, project_name: str) -> dict[str, object]: + if configuration == "Fuzz": + name = fuzz_build_node_name(project_name.removeprefix("fuzz-")) + else: + name = build_project_node_name(configuration, project_name) + binary_extension = ( + ".exe" + if project_name in ("tests", "leak-probe") or project_name.startswith("fuzz-") + else ".so" + ) + return _node( + name, + deps=["source-checks"], + resources={"cpu": 4, "memory-gib": 2, "native-msbuild": 1}, + argv=_leaf_argv( + "build-project", + "-Arch", + "x64", + "-Config", + configuration, + "-Project", + project_name, + ), + inputs=[ + "build/**/*.props", + f"build/projects/{project_name}.vcxproj", + "src/**/*.cpp", + "src/**/*.h", + "vcpkg.json", + ], + outputs=[f".artifacts/bin/x64/{configuration}/{project_name}{binary_extension}"], + writes=_binary_writes(configuration, project_name), + ) + + +def _test_node(configuration: str) -> dict[str, object]: + config_key = configuration.lower() + report = f".artifacts/reports/tests/tests-x64-{config_key}.xml" + return _node( + f"run-tests-{config_key}", + deps=sorted(f"build-{config_key}-{project}" for project in RUNTIME_PROJECT_NAMES), + resources={"cpu": 1, "test-run": 1}, + argv=_leaf_argv("run-tests", "-Arch", "x64", "-Config", configuration), + inputs=[f".artifacts/bin/x64/{configuration}/*"], + outputs=[report], + writes=[report], + ) + + +def _msvc_analysis_nodes(unit: TranslationUnit) -> tuple[dict[str, object], dict[str, object]]: + configuration = "Release" if unit.project == "leak-probe" else "Debug" + node_suffix = f"{unit.project}-{unit.slug}" + scratch = f".artifacts/analysis/msvc/x64/{unit.project}/{unit.slug}/" + raw = f"{scratch}{unit.project}.sarif" + normalized = f".artifacts/reports/msvc/x64/units/{node_suffix}.sarif" + analyze_name = f"analyze-msvc-{node_suffix}" + analyze = _node( + analyze_name, + deps=["source-checks"], + resources={"cpu": 1, "memory-gib": 2, "msvc-analysis": 1}, + argv=_leaf_argv( + "analyze-msvc-project", + "-Arch", + "x64", + "-Config", + configuration, + "-Project", + unit.project, + "-SelectedFile", + unit.source.as_posix(), + "-Unit", + unit.slug, + ), + inputs=[ + "build/ObserverNativeAnalysis.ruleset", + "build/**/*.props", + f"build/projects/{unit.project}.vcxproj", + unit.source.as_posix(), + ], + outputs=[raw], + writes=[scratch], + ) + normalize = _node( + f"normalize-msvc-{node_suffix}", + deps=[analyze_name], + resources={"cpu": 1, "sarif": 1}, + argv=_leaf_argv( + "normalize-msvc-sarif", + "-Arch", + "x64", + "-Project", + unit.project, + "-Unit", + unit.slug, + "-Input", + raw, + "-Output", + normalized, + ), + inputs=[raw], + outputs=[normalized], + writes=[normalized], + ) + return analyze, normalize + + +def _tidy_analysis_nodes(unit: TranslationUnit) -> tuple[dict[str, object], dict[str, object]]: + node_suffix = f"{unit.project}-{unit.slug}" + scratch = f".artifacts/analysis/clang-tidy/x64/{unit.project}/{unit.slug}/" + object_root = f"{scratch}obj/" + raw_log = f"{object_root}{unit.project}.ClangTidy.log" + normalized = f".artifacts/reports/clang-tidy/x64/units/{node_suffix}.sarif" + analyze_name = f"analyze-tidy-{node_suffix}" + analyze = _node( + analyze_name, + deps=["source-checks"], + resources={"clang-tidy": 1, "cpu": 1}, + argv=_leaf_argv( + "analyze-tidy-unit", + "-Arch", + "x64", + "-Project", + unit.project, + "-SelectedFile", + unit.source.as_posix(), + "-Unit", + unit.slug, + ), + inputs=[ + ".clang-tidy", + "build/**/*.props", + f"build/projects/{unit.project}.vcxproj", + unit.source.as_posix(), + ], + outputs=[raw_log], + writes=[scratch], + ) + normalize = _node( + f"normalize-tidy-{node_suffix}", + deps=[analyze_name], + resources={"cpu": 1, "sarif": 1}, + argv=_leaf_argv( + "normalize-tidy-sarif", + "-Arch", + "x64", + "-Project", + unit.project, + "-Unit", + unit.slug, + "-InputRoot", + object_root, + "-Output", + normalized, + ), + inputs=[raw_log], + outputs=[normalized], + writes=[normalized], + ) + return analyze, normalize + + +def expand_x64_nodes(manifest: Manifest) -> list[dict[str, object]]: + """Return schema-v2 normalized records; profile-level roots are added by the caller.""" + + nodes: list[dict[str, object]] = [] + for configuration in RUNTIME_CONFIGURATIONS: + nodes.extend(_build_node(configuration, project) for project in RUNTIME_PROJECT_NAMES) + nodes.append(_test_node(configuration)) + for project in FUZZ_PROJECT_NAMES: + nodes.append(_build_node("Fuzz", project)) + nodes.append(_build_node("Release", "leak-probe")) + + msvc_normalizers: list[str] = [] + msvc_reports: list[str] = [] + for unit in manifest.translation_units: + analyze, normalize = _msvc_analysis_nodes(unit) + nodes.extend((analyze, normalize)) + msvc_normalizers.append(str(normalize["name"])) + msvc_reports.append(str(normalize["outputs"][0])) + + tidy_normalizers: list[str] = [] + tidy_reports: list[str] = [] + for unit in manifest.translation_units: + analyze, normalize = _tidy_analysis_nodes(unit) + nodes.extend((analyze, normalize)) + tidy_normalizers.append(str(normalize["name"])) + tidy_reports.append(str(normalize["outputs"][0])) + + msvc_merged = ".artifacts/reports/msvc/x64/msvc-analyze.sarif" + tidy_merged = ".artifacts/reports/clang-tidy/x64/clang-tidy.sarif" + nodes.append( + _node( + "merge-msvc-sarif", + deps=msvc_normalizers, + resources={"cpu": 1, "sarif": 1}, + argv=_leaf_argv( + "merge-sarif", + "-Arch", + "x64", + "-Backend", + "msvc", + "-InputPathsJson", + json.dumps(msvc_reports, separators=(",", ":")), + "-Output", + msvc_merged, + ), + inputs=msvc_reports, + outputs=[msvc_merged], + writes=[msvc_merged], + ) + ) + nodes.append( + _node( + "merge-tidy-sarif", + deps=tidy_normalizers, + resources={"cpu": 1, "sarif": 1}, + argv=_leaf_argv( + "merge-sarif", + "-Arch", + "x64", + "-Backend", + "clang-tidy", + "-InputPathsJson", + json.dumps(tidy_reports, separators=(",", ":")), + "-Output", + tidy_merged, + ), + inputs=tidy_reports, + outputs=[tidy_merged], + writes=[tidy_merged], + ) + ) + gate_report = ".artifacts/reports/analysis/x64/gate.json" + nodes.append( + _node( + "analysis-gate", + deps=["merge-msvc-sarif", "merge-tidy-sarif"], + resources={"cpu": 1, "sarif": 1}, + argv=_leaf_argv( + "analysis-gate", + "-Arch", + "x64", + "-MsvcReport", + msvc_merged, + "-ClangTidyReport", + tidy_merged, + ), + inputs=[msvc_merged, tidy_merged], + outputs=[gate_report], + writes=[gate_report], + ) + ) + return nodes diff --git a/build/projects/fuzz-pickle.vcxproj b/build/projects/fuzz-pickle.vcxproj new file mode 100644 index 0000000..d7f0a77 --- /dev/null +++ b/build/projects/fuzz-pickle.vcxproj @@ -0,0 +1,36 @@ + + + + + 17.0 + {202A197D-CF6A-47D3-A379-64241A579795} + Win32Proj + fuzz_pickle + 10.0 + Application + + + + + + + + + + fuzz-pickle + + + + $(IntDir)pickle_fuzzer.obj + + + $(IntDir)pickle_parser.obj + + + + + + + + + diff --git a/build/projects/fuzz-renpy.vcxproj b/build/projects/fuzz-renpy.vcxproj new file mode 100644 index 0000000..70a9cd1 --- /dev/null +++ b/build/projects/fuzz-renpy.vcxproj @@ -0,0 +1,45 @@ + + + + + 17.0 + {086A7516-3492-4D13-8BD5-11123490C810} + Win32Proj + fuzz_renpy + 10.0 + Application + + + + + + + + + + fuzz-renpy + + + + zs.lib;%(AdditionalDependencies) + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + + + + + diff --git a/build/projects/fuzz-rpgmaker.vcxproj b/build/projects/fuzz-rpgmaker.vcxproj new file mode 100644 index 0000000..ea27dec --- /dev/null +++ b/build/projects/fuzz-rpgmaker.vcxproj @@ -0,0 +1,37 @@ + + + + + 17.0 + {DF60A28B-D6D4-4EF0-811B-4CC53ED93070} + Win32Proj + fuzz_rpgmaker + 10.0 + Application + + + + + + + + + + fuzz-rpgmaker + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + diff --git a/build/projects/fuzz-zanzarah.vcxproj b/build/projects/fuzz-zanzarah.vcxproj new file mode 100644 index 0000000..66615d9 --- /dev/null +++ b/build/projects/fuzz-zanzarah.vcxproj @@ -0,0 +1,37 @@ + + + + + 17.0 + {F56AF8F0-E552-41DB-85E9-8131548828E6} + Win32Proj + fuzz_zanzarah + 10.0 + Application + + + + + + + + + + fuzz-zanzarah + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + diff --git a/build/projects/leak-probe.vcxproj b/build/projects/leak-probe.vcxproj new file mode 100644 index 0000000..72b43dc --- /dev/null +++ b/build/projects/leak-probe.vcxproj @@ -0,0 +1,51 @@ + + + + + 17.0 + {3690B34C-A1CA-448D-A301-119156269054} + Win32Proj + leak_probe + 10.0 + Application + + + + + + + + + leak-probe + + + + MultiThreaded + + + zs.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/renpy.vcxproj b/build/projects/renpy.vcxproj new file mode 100644 index 0000000..1c2bab7 --- /dev/null +++ b/build/projects/renpy.vcxproj @@ -0,0 +1,48 @@ + + + + + 17.0 + {4E8AE1A2-12B2-4A87-9B28-62E3E7CDB510} + Win32Proj + renpy + 10.0 + DynamicLibrary + + + + + + + + + renpy + .so + + + + $(RepositoryRoot)src\modules\renpy\renpy.def + zsd.lib;%(AdditionalDependencies) + zs.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/rpgmaker.vcxproj b/build/projects/rpgmaker.vcxproj new file mode 100644 index 0000000..148b648 --- /dev/null +++ b/build/projects/rpgmaker.vcxproj @@ -0,0 +1,43 @@ + + + + + 17.0 + {7DF16D34-0324-4AC4-B99F-637D2A7E53E8} + Win32Proj + rpgmaker + 10.0 + DynamicLibrary + + + + + + + + + rpgmaker + .so + + + + $(RepositoryRoot)src\modules\rpgmaker\rpgmaker.def + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/tests.vcxproj b/build/projects/tests.vcxproj new file mode 100644 index 0000000..3cc6a25 --- /dev/null +++ b/build/projects/tests.vcxproj @@ -0,0 +1,66 @@ + + + + + 17.0 + {C074B2CD-C481-4C20-A349-902E02D39C6D} + Win32Proj + tests + 10.0 + Application + + + + + + + + + tests + debug\ + + + + $(VcpkgInstalledDir)$(VcpkgTriplet)\$(ObserverVcpkgDebugPrefix)lib\manual-link;%(AdditionalLibraryDirectories) + Catch2d.lib;xxhash.lib;zsd.lib;%(AdditionalDependencies) + Catch2.lib;xxhash.lib;zs.lib;%(AdditionalDependencies) + + + + + $(IntDir)bounded_stream_core.obj + + + + + + + + + + + + + $(IntDir)pickle_unit.obj + + + + $(IntDir)bounded_stream_unit.obj + + + $(IntDir)pickle_parser.obj + + + + + + + + + + + + + + + diff --git a/build/projects/zanzarah.vcxproj b/build/projects/zanzarah.vcxproj new file mode 100644 index 0000000..c557929 --- /dev/null +++ b/build/projects/zanzarah.vcxproj @@ -0,0 +1,43 @@ + + + + + 17.0 + {B313D87B-B15E-4E0E-92F9-DA04231CC41A} + Win32Proj + zanzarah + 10.0 + DynamicLibrary + + + + + + + + + zanzarah + .so + + + + $(RepositoryRoot)src\modules\zanzarah\zanzarah.def + + + + + + + + + + + + + + + + + + + diff --git a/build/run-msbuild.cmd b/build/run-msbuild.cmd new file mode 100644 index 0000000..482d0a9 --- /dev/null +++ b/build/run-msbuild.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +set "OBSERVER_CLEAN_PATH=%PATH%" +set "OBSERVER_CLEAN_LIB=%LIB%" +set "Path=" +set "PATH=" +set "Lib=" +set "LIB=" +set "PATH=%OBSERVER_CLEAN_PATH%" +set "LIB=%OBSERVER_CLEAN_LIB%" +"%OBSERVER_MSBUILD_EXE%" %* +exit /b %ERRORLEVEL% diff --git a/build/tests/analysis-reporting.Tests.ps1 b/build/tests/analysis-reporting.Tests.ps1 new file mode 100644 index 0000000..3d014f4 --- /dev/null +++ b/build/tests/analysis-reporting.Tests.ps1 @@ -0,0 +1,123 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$modulePath = Join-Path $repositoryRoot 'build\lib\analysis-reporting.ps1' +if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "Analysis-reporting production module was not found: $modulePath" +} +. $modulePath + +function Assert-Equal { + param( + [Parameter(Mandatory)][AllowNull()] $Actual, + [Parameter(Mandatory)][AllowNull()] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if (-not [object]::Equals($Actual, $Expected)) { + throw "${Description}: actual='$Actual', expected='$Expected'." + } +} + +$testRoot = Join-Path $repositoryRoot ".artifacts\contract-tests\analysis-reporting-$([guid]::NewGuid().ToString('N'))" +$objectRoot = Join-Path $testRoot 'obj\x64' +$reportPath = Join-Path $testRoot 'reports\clang-tidy\x64\clang-tidy.sarif' + +try { + $renpyLogDirectory = Join-Path $objectRoot 'Debug\renpy' + $testsLogDirectory = Join-Path $objectRoot 'Debug\tests' + New-Item -ItemType Directory -Force -Path $renpyLogDirectory, $testsLogDirectory | Out-Null + + $apiPath = Join-Path $repositoryRoot 'src\api.h' + $archivePath = Join-Path $repositoryRoot 'src\archive.cpp' + $outsidePath = Join-Path (Split-Path $repositoryRoot -Parent) 'dependency\upstream.cpp' + @( + "[1/2] Processing file $apiPath." + "${apiPath}(28,9): error : declaration uses a reserved identifier [bugprone-reserved-identifier,-warnings-as-errors] [$repositoryRoot\build\projects\renpy.vcxproj]" + "${archivePath}(41,7): warning : prefer a scoped lock [bugprone-lock-mutex] [$repositoryRoot\build\projects\renpy.vcxproj]" + "${archivePath}(41,7): message : diagnostic note without an independent rule [$repositoryRoot\build\projects\renpy.vcxproj]" + "${outsidePath}(5,2): warning : dependency warning [bugprone-example] [$repositoryRoot\build\projects\renpy.vcxproj]" + 'Suppressed 123 warnings (123 in non-user code).' + ) | Set-Content -LiteralPath (Join-Path $renpyLogDirectory 'renpy.ClangTidy.log') -Encoding utf8 + @( + "${apiPath}(28,9): error : declaration uses a reserved identifier [bugprone-reserved-identifier,-warnings-as-errors] [$repositoryRoot\build\projects\tests.vcxproj]" + ) | Set-Content -LiteralPath (Join-Path $testsLogDirectory 'tests.ClangTidy.log') -Encoding utf8 + + Export-ClangTidySarif ` + -RepositoryRoot $repositoryRoot ` + -ObjectRoot $objectRoot ` + -OutputPath $reportPath ` + -Architecture 'x64' + + if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) { + throw "Clang-tidy SARIF was not created: $reportPath" + } + $sarif = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json + Assert-Equal -Actual $sarif.version -Expected '2.1.0' -Description 'SARIF version' + Assert-Equal -Actual @($sarif.runs).Count -Expected 1 -Description 'SARIF run count' + + $run = @($sarif.runs)[0] + Assert-Equal -Actual $run.tool.driver.name -Expected 'clang-tidy' -Description 'SARIF driver name' + Assert-Equal -Actual $run.automationDetails.id -Expected 'clang-tidy/x64/' -Description 'Stable run identity' + Assert-Equal -Actual @($run.results).Count -Expected 2 -Description 'Deduplicated first-party result count' + + $results = @($run.results | Sort-Object ruleId) + Assert-Equal -Actual $results[0].ruleId -Expected 'bugprone-lock-mutex' -Description 'Warning rule ID' + Assert-Equal -Actual $results[0].level -Expected 'warning' -Description 'Warning SARIF level' + Assert-Equal -Actual $results[0].locations[0].physicalLocation.artifactLocation.uri -Expected 'src/archive.cpp' -Description 'Normalized warning URI' + Assert-Equal -Actual $results[1].ruleId -Expected 'bugprone-reserved-identifier' -Description 'Error rule ID' + Assert-Equal -Actual $results[1].level -Expected 'error' -Description 'Error SARIF level' + Assert-Equal -Actual ([int] $results[1].locations[0].physicalLocation.region.startLine) -Expected 28 -Description 'Error line' + Assert-Equal -Actual ([int] $results[1].locations[0].physicalLocation.region.startColumn) -Expected 9 -Description 'Error column' + Assert-Equal -Actual @($run.tool.driver.rules).Count -Expected 2 -Description 'Unique rule metadata count' + + $emptyObjectRoot = Join-Path $testRoot 'obj\arm64' + $emptyReportPath = Join-Path $testRoot 'reports\clang-tidy\arm64\clang-tidy.sarif' + New-Item -ItemType Directory -Force -Path $emptyObjectRoot | Out-Null + Export-ClangTidySarif ` + -RepositoryRoot $repositoryRoot ` + -ObjectRoot $emptyObjectRoot ` + -OutputPath $emptyReportPath ` + -Architecture 'arm64' + $emptySarif = Get-Content -Raw -LiteralPath $emptyReportPath | ConvertFrom-Json + Assert-Equal -Actual @($emptySarif.runs[0].results).Count -Expected 0 -Description 'Empty result count' + Assert-Equal -Actual $emptySarif.runs[0].automationDetails.id -Expected 'clang-tidy/arm64/' -Description 'Empty run identity' + + $msvcReportDirectory = Join-Path $testRoot 'reports\msvc\x64' + New-Item -ItemType Directory -Force -Path $msvcReportDirectory | Out-Null + foreach ($reportName in @('renpy', 'tests')) { + [ordered]@{ + version = '2.1.0' + runs = @( + [ordered]@{ + tool = [ordered]@{ driver = [ordered]@{ name = 'Microsoft C/C++ Code Analysis' } } + results = @([ordered]@{ ruleId = 'C6001' }) + } + ) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $msvcReportDirectory "$reportName.sarif") -Encoding utf8 + } + + Set-MSVCAnalysisSarifIdentity -ReportDirectory $msvcReportDirectory -Architecture 'x64' + foreach ($reportName in @('renpy', 'tests')) { + $report = Get-Content -Raw -LiteralPath (Join-Path $msvcReportDirectory "$reportName.sarif") | ConvertFrom-Json + Assert-Equal ` + -Actual $report.runs[0].automationDetails.id ` + -Expected "msvc-analyze/x64/$reportName/" ` + -Description "$reportName MSVC run identity" + Assert-Equal -Actual @($report.runs[0].results).Count -Expected 1 -Description "$reportName result preservation" + } +} finally { + if (Test-Path -LiteralPath $testRoot) { + $resolvedTestRoot = [System.IO.Path]::GetFullPath($testRoot) + $expectedParent = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts\contract-tests')) + if (-not $resolvedTestRoot.StartsWith($expectedParent + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove unexpected contract-test path: $resolvedTestRoot" + } + Remove-Item -Recurse -Force -LiteralPath $resolvedTestRoot + } +} + +Write-Host '[OK] Clang-tidy logs convert to deterministic first-party SARIF.' diff --git a/build/tests/build-entrypoint-contract.Tests.ps1 b/build/tests/build-entrypoint-contract.Tests.ps1 new file mode 100644 index 0000000..7ae147e --- /dev/null +++ b/build/tests/build-entrypoint-contract.Tests.ps1 @@ -0,0 +1,295 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Read-PowerShellAst { + param([Parameter(Mandatory)][string] $Path) + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) + if ($errors.Count -ne 0) { + $messages = @($errors | ForEach-Object Message) + throw "PowerShell parse failed for '$Path': $($messages -join '; ')" + } + return $ast +} + +function Assert-SequenceEqual { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Actual, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if ($Actual.Count -ne $Expected.Count) { + throw "$Description count differs: actual=$($Actual.Count), expected=$($Expected.Count)." + } + for ($index = 0; $index -lt $Expected.Count; ++$index) { + if (-not [object]::Equals($Actual[$index], $Expected[$index])) { + throw "$Description differs at index ${index}: actual='$($Actual[$index])', expected='$($Expected[$index])'." + } + } +} + +function Get-HelpOutput { + param( + [Parameter(Mandatory)][string] $PowerShellPath, + [Parameter(Mandatory)][string] $ScriptPath + ) + + $output = @(& $PowerShellPath -NoLogo -NoProfile -File $ScriptPath help 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Help command failed for '$ScriptPath' with exit code $LASTEXITCODE." + } + return ((@($output | ForEach-Object { $_.ToString() }) -join "`n") -replace "`r`n?", "`n").TrimEnd() +} + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$rootEntrypoint = Join-Path $repositoryRoot 'build.ps1' +$internalEntrypoint = Join-Path $repositoryRoot 'build\build.ps1' +$commonLibrary = Join-Path $repositoryRoot 'build\lib\common.ps1' +$powerShellPath = Join-Path $PSHOME 'pwsh.exe' + +$rootText = Get-Content -Raw -LiteralPath $rootEntrypoint +$expectedForwarder = "& (Join-Path `$PSScriptRoot 'build\build.ps1') @args" +if (-not $rootText.Contains($expectedForwarder, [StringComparison]::Ordinal)) { + throw 'The root build.ps1 entrypoint no longer forwards arguments unchanged.' +} + +$internalAst = Read-PowerShellAst -Path $internalEntrypoint +$commandParameter = @( + $internalAst.ParamBlock.Parameters | + Where-Object { $_.Name.VariablePath.UserPath -eq 'Command' } +) +if ($commandParameter.Count -ne 1) { + throw 'The internal entrypoint must expose exactly one Command parameter.' +} +$validateSet = @( + $commandParameter[0].Attributes | + Where-Object { $_.TypeName.FullName -eq 'ValidateSet' } +) +if ($validateSet.Count -ne 1) { + throw 'The Command parameter must retain one ValidateSet contract.' +} +$actualCommands = @($validateSet[0].PositionalArguments | ForEach-Object { $_.SafeGetValue() }) +$expectedCommands = @( + 'help', + 'doctor', + 'restore', + 'build', + 'test', + 'source-checks', + 'compiler-analysis', + 'test-coverage', + 'test-asan', + 'test-ubsan', + 'test-leaks', + 'fuzz', + 'audit-binaries', + 'package', + 'verify', + 'clean' +) +Assert-SequenceEqual -Actual $actualCommands -Expected $expectedCommands -Description 'Command ValidateSet' + +$restoreFlavorParameter = @( + $internalAst.ParamBlock.Parameters | + Where-Object { $_.Name.VariablePath.UserPath -eq 'RestoreFlavor' } +) +if ($restoreFlavorParameter.Count -ne 1) { + throw 'The internal entrypoint must expose exactly one RestoreFlavor parameter.' +} +$restoreFlavorValidateSet = @( + $restoreFlavorParameter[0].Attributes | + Where-Object { $_.TypeName.FullName -eq 'ValidateSet' } +) +if ($restoreFlavorValidateSet.Count -ne 1) { + throw 'RestoreFlavor must retain one ValidateSet contract.' +} +Assert-SequenceEqual ` + -Actual @($restoreFlavorValidateSet[0].PositionalArguments | ForEach-Object { $_.SafeGetValue() }) ` + -Expected @('default', 'asan', 'all') ` + -Description 'RestoreFlavor ValidateSet' + +$skipRestoreParameter = @( + $internalAst.ParamBlock.Parameters | + Where-Object { $_.Name.VariablePath.UserPath -eq 'SkipDependencyRestore' } +) +if ($skipRestoreParameter.Count -ne 1 -or + $skipRestoreParameter[0].StaticType -ne [System.Management.Automation.SwitchParameter]) { + throw 'The internal entrypoint must expose one SkipDependencyRestore switch.' +} + +$expectedHelp = @' +ObserverModules build entry point + + doctor inspect the complete toolchain + restore -Arch restore pinned static vcpkg dependencies + build -Arch build modules and tests + test -Arch build and run deterministic/corpus tests + source-checks -Arch clang-format, Cppcheck, PSScriptAnalyzer + compiler-analysis -Arch MSVC /analyze plus clang-tidy + test-coverage -Arch run tests and enforce llvm-cov source coverage + test-asan -Arch build dependencies/code and run tests with MSVC ASan + test-ubsan -Arch x64 build and run tests with clang-cl UBSan + test-leaks -Arch x64 run UMDH operation and DLL-lifecycle leak checks + fuzz -Arch x64 build and run one or all libFuzzer targets + audit-binaries -Arch build and inspect Release PE files with dumpbin + package -Arch module ZIPs plus one combined PDB ZIP + verify -Arch complete host-capable gate with explicit native deferrals + clean remove .artifacts + +Options: + -Config Debug|Release + -Corpus + -RestoreFlavor default|asan|all restore only; "all" prepares serial DAG dependency flavors + -SkipDependencyRestore DAG leaf only; dependencies must already be restored + -FuzzSeconds + -FuzzTarget pickle, renpy, rpgmaker, zanzarah, or all (default) + -LeakWarmup + -LeakIterations + -LeakWindows <3..10> + -LeakToleranceBytes defaults to zero; use only for a reviewed stack-specific exception + -CoverageThreshold <0..100> defaults to 100 for source lines and branches +'@.TrimEnd() +$rootHelp = Get-HelpOutput -PowerShellPath $powerShellPath -ScriptPath $rootEntrypoint +$internalHelp = Get-HelpOutput -PowerShellPath $powerShellPath -ScriptPath $internalEntrypoint +if (-not $rootHelp.Equals($expectedHelp, [StringComparison]::Ordinal)) { + throw 'The public root help output changed.' +} +if (-not $internalHelp.Equals($expectedHelp, [StringComparison]::Ordinal)) { + throw 'The internal help output differs from the public CLI contract.' +} + +$entrypointText = Get-Content -Raw -LiteralPath $internalEntrypoint +$dotSourceMatches = [regex]::Matches( + $entrypointText, + "(?m)^\.\s+\(Join-Path\s+\`$script:BuildRoot\s+'lib\\([^']+)'\)\s*`$" +) +$actualLoadOrder = @($dotSourceMatches | ForEach-Object { $_.Groups[1].Value }) +Assert-SequenceEqual ` + -Actual $actualLoadOrder ` + -Expected @( + 'package-manifest.ps1', + 'analysis-reporting.ps1', + 'common.ps1', + 'package-smoke.ps1', + 'verify-routing.ps1', + 'packaging.ps1', + 'verify.ps1' + ) ` + -Description 'Entrypoint library load order' + +if (-not (Test-Path -LiteralPath $commonLibrary -PathType Leaf)) { + throw "Common build library is missing: $commonLibrary" +} +$commonAst = Read-PowerShellAst -Path $commonLibrary +$expectedCommonFunctions = @( + 'Write-Step', + 'Invoke-Native', + 'Invoke-NativeCapture', + 'Get-RequestedArchitecture', + 'Get-MSBuildPlatform', + 'Get-VcpkgTriplet', + 'Get-BinaryDirectory', + 'Resolve-UserPath' +) +$actualCommonFunctions = @( + $commonAst.FindAll( + { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $false + ) | + ForEach-Object Name +) +Assert-SequenceEqual -Actual $actualCommonFunctions -Expected $expectedCommonFunctions -Description 'Common helper surface' + +$entrypointFunctionNames = @( + $internalAst.FindAll( + { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $false + ) | + ForEach-Object Name +) +$duplicateCommonFunctions = @($expectedCommonFunctions | Where-Object { $_ -in $entrypointFunctionNames }) +if ($duplicateCommonFunctions.Count -ne 0) { + throw "Common helpers remain duplicated in the entrypoint: $($duplicateCommonFunctions -join ', ')." +} + +$restoreFunction = @( + $internalAst.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-Restore' + }, + $false + ) +) +if ($restoreFunction.Count -ne 1) { + throw 'The entrypoint must define exactly one Invoke-Restore function.' +} +$restoreFunctionText = $restoreFunction[0].Extent.Text +$skipGuardIndex = $restoreFunctionText.IndexOf('if ($SkipDependencyRestore)', [StringComparison]::Ordinal) +$skipReturnIndex = $restoreFunctionText.IndexOf('return', $skipGuardIndex, [StringComparison]::Ordinal) +$toolDiscoveryIndex = $restoreFunctionText.IndexOf('Resolve-Vcpkg', [StringComparison]::Ordinal) +if ($skipGuardIndex -lt 0 -or $skipReturnIndex -lt $skipGuardIndex -or $toolDiscoveryIndex -lt $skipReturnIndex) { + throw 'SkipDependencyRestore must return before vcpkg discovery and execution.' +} +if ($restoreFunctionText -notmatch "'all'\) \{ @\('default', 'asan'\) \}") { + throw 'RestoreFlavor all must expand to serial default and ASan restores.' +} + +$rejectedRestoreOutput = @( + & $powerShellPath -NoLogo -NoProfile -File $internalEntrypoint ` + restore -Arch x64 -SkipDependencyRestore 2>&1 +) +if ($LASTEXITCODE -eq 0 -or + ($rejectedRestoreOutput -join "`n") -notmatch 'cannot be used with the restore command') { + throw 'The restore command must reject SkipDependencyRestore instead of reporting a false success.' +} + +$script:BuildRoot = Join-Path $repositoryRoot 'build' +$script:RepositoryRoot = $repositoryRoot +$script:AggregateProject = Join-Path $script:BuildRoot 'ObserverModules.proj' +$script:ArtifactsRoot = Join-Path $repositoryRoot '.artifacts' +$script:KnownArchitectures = @('x86', 'x64', 'arm64') +. $commonLibrary + +Assert-SequenceEqual ` + -Actual @(Get-RequestedArchitecture -Requested @('all')) ` + -Expected @('x86', 'x64', 'arm64') ` + -Description 'All-architecture expansion' +Assert-SequenceEqual ` + -Actual @( + Get-MSBuildPlatform -Architecture 'x86' + Get-MSBuildPlatform -Architecture 'x64' + Get-MSBuildPlatform -Architecture 'arm64' + ) ` + -Expected @('Win32', 'x64', 'ARM64') ` + -Description 'MSBuild platform mapping' +Assert-SequenceEqual ` + -Actual @( + Get-VcpkgTriplet -Architecture 'x64' + Get-VcpkgTriplet -Architecture 'x64' -Flavor 'asan' + ) ` + -Expected @('observer-x64-windows-static', 'observer-x64-windows-static-asan') ` + -Description 'vcpkg triplet mapping' + +$expectedBinaryDirectory = Join-Path $script:ArtifactsRoot 'bin\x64\Release' +$actualBinaryDirectory = Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release' +if (-not $actualBinaryDirectory.Equals($expectedBinaryDirectory, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Get-BinaryDirectory no longer uses the explicit build context.' +} +$expectedUserPath = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'docs')) +$actualUserPath = Resolve-UserPath -Path 'docs' +if (-not $actualUserPath.Equals($expectedUserPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Resolve-UserPath no longer resolves relative to the repository root.' +} +$capture = Invoke-NativeCapture ` + -FilePath $powerShellPath ` + -Arguments @('-NoLogo', '-NoProfile', '-Command', "Write-Output 'common-capture'") +Assert-SequenceEqual -Actual @($capture) -Expected @('common-capture') -Description 'Native capture helper' + +Write-Host '[OK] Build entrypoint CLI and common-library scope contracts are stable.' diff --git a/build/tests/ci-reporting-contract.Tests.ps1 b/build/tests/ci-reporting-contract.Tests.ps1 new file mode 100644 index 0000000..81b455b --- /dev/null +++ b/build/tests/ci-reporting-contract.Tests.ps1 @@ -0,0 +1,123 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$workflowPath = Join-Path $repositoryRoot '.github\workflows\main.yml' +$buildScriptPath = Join-Path $repositoryRoot 'build\build.ps1' +$workflow = Get-Content -Raw -LiteralPath $workflowPath +$buildScript = Get-Content -Raw -LiteralPath $buildScriptPath + +function Assert-ContainsText { + param( + [Parameter(Mandatory)][string] $Text, + [Parameter(Mandatory)][string] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if (-not $Text.Contains($Expected, [System.StringComparison]::Ordinal)) { + throw "$Description is missing '$Expected'." + } +} + +Assert-ContainsText ` + -Text $buildScript ` + -Expected ". (Join-Path `$script:BuildRoot 'lib\analysis-reporting.ps1')" ` + -Description 'Build entry-point analysis-reporting import' + +$analysisFunctionMatch = [regex]::Match( + $buildScript, + '(?s)function Invoke-CodeAnalysis \{(?.*?)\r?\n\}\r?\n\r?\nfunction Add-ASanRuntimeToPath' +) +if (-not $analysisFunctionMatch.Success) { + throw 'Invoke-CodeAnalysis could not be isolated for reporting-contract validation.' +} +$analysisFunction = $analysisFunctionMatch.Groups['body'].Value +foreach ($requiredFragment in @( + 'try {', + '} finally {', + 'Set-MSVCAnalysisSarifIdentity', + 'Export-ClangTidySarif', + "reports\clang-tidy", + 'clang-tidy.sarif' +)) { + Assert-ContainsText -Text $analysisFunction -Expected $requiredFragment -Description 'Compiler-analysis reporting contract' +} + +$expectedUploads = @( + @{ + Name = 'Cppcheck x86' + File = '.artifacts/reports/cppcheck/cppcheck-x86.sarif' + Category = 'cppcheck/x86' + }, + @{ + Name = 'Cppcheck x64' + File = '.artifacts/reports/cppcheck/cppcheck-x64.sarif' + Category = 'cppcheck/x64' + }, + @{ + Name = 'Cppcheck ARM64' + File = '.artifacts/reports/cppcheck/cppcheck-arm64.sarif' + Category = 'cppcheck/arm64' + }, + @{ + Name = 'MSVC' + File = '.artifacts/reports/msvc/${{ matrix.arch }}' + Category = 'msvc-analyze/${{ matrix.arch }}' + }, + @{ + Name = 'clang-tidy' + File = '.artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif' + Category = 'clang-tidy/${{ matrix.arch }}' + }, + @{ + Name = 'BinSkim' + File = '.artifacts/audit/binskim-${{ matrix.arch }}.sarif' + Category = 'binskim/${{ matrix.arch }}' + } +) +foreach ($upload in $expectedUploads) { + Assert-ContainsText -Text $workflow -Expected "sarif_file: $($upload.File)" -Description "$($upload.Name) SARIF upload" + Assert-ContainsText -Text $workflow -Expected "category: $($upload.Category)" -Description "$($upload.Name) category" +} + +Assert-ContainsText -Text $workflow -Expected '- name: Upload clang-tidy SARIF' -Description 'clang-tidy upload step' +Assert-ContainsText -Text $workflow -Expected '.artifacts/reports/clang-tidy/${{ matrix.arch }}' -Description 'Archived clang-tidy evidence' + +foreach ($codeQlFragment in @( + '- name: Analyze and upload CodeQL SARIF', + 'category: codeql/c-cpp', + 'output: .artifacts/reports/codeql/raw', + 'post-processed-sarif-path: .artifacts/reports/codeql/uploaded', + '- name: Archive CodeQL SARIF', + 'name: codeql-sarif', + 'path: .artifacts/reports/codeql' +)) { + Assert-ContainsText -Text $workflow -Expected $codeQlFragment -Description 'CodeQL retained-report contract' +} + +$workflowSteps = [regex]::Matches( + $workflow, + '(?ms)^ - name: (?[^\r\n]+)\r?\n(?.*?)(?=^ - |^ \S|\z)' +) +$sarifUploadSteps = @( + $workflowSteps | + Where-Object { $_.Groups['body'].Value.Contains('uses: github/codeql-action/upload-sarif@v4') } +) +if ($sarifUploadSteps.Count -eq 0) { + throw 'No third-party SARIF upload steps were found.' +} +foreach ($uploadStep in $sarifUploadSteps) { + $uploadBody = $uploadStep.Groups['body'].Value + Assert-ContainsText ` + -Text $uploadBody ` + -Expected 'if: always() && hashFiles(' ` + -Description "$($uploadStep.Groups['name'].Value) report-existence guard" + Assert-ContainsText ` + -Text $uploadBody ` + -Expected "github.event.pull_request.head.repo.full_name == github.repository" ` + -Description "$($uploadStep.Groups['name'].Value) fork-permission guard" +} + +Write-Host '[OK] CI retains and uniquely categorizes Cppcheck, MSVC, clang-tidy, CodeQL, and BinSkim SARIF.' diff --git a/build/tests/compiler-analysis-graph.Tests.ps1 b/build/tests/compiler-analysis-graph.Tests.ps1 new file mode 100644 index 0000000..65e4092 --- /dev/null +++ b/build/tests/compiler-analysis-graph.Tests.ps1 @@ -0,0 +1,253 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$namespace = [System.Xml.XmlNamespaceManager]::new([System.Xml.NameTable]::new()) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + +function Read-MSBuildProject { + param([Parameter(Mandatory)][string] $Path) + + [xml] $project = Get-Content -Raw -LiteralPath $Path + return $project +} + +function Assert-SequenceEqual { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Actual, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if ($Actual.Count -ne $Expected.Count) { + throw "$Description count differs: actual=$($Actual.Count), expected=$($Expected.Count)." + } + for ($index = 0; $index -lt $Expected.Count; ++$index) { + if (-not [object]::Equals($Actual[$index], $Expected[$index])) { + throw "$Description differs at index ${index}: actual='$($Actual[$index])', expected='$($Expected[$index])'." + } + } +} + +$aggregateProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverModules.proj') +$analysisProjects = @($aggregateProject.SelectNodes('//msb:AnalysisProject', $namespace)) +$expectedManifest = @( + 'renpy.vcxproj|renpy|Debug|Win32;x64;ARM64', + 'rpgmaker.vcxproj|rpgmaker|Debug|Win32;x64;ARM64', + 'zanzarah.vcxproj|zanzarah|Debug|Win32;x64;ARM64', + 'tests.vcxproj|tests|Debug|Win32;x64;ARM64', + 'fuzz-pickle.vcxproj|fuzz-pickle|Debug|Win32;x64;ARM64', + 'fuzz-renpy.vcxproj|fuzz-renpy|Debug|Win32;x64;ARM64', + 'fuzz-rpgmaker.vcxproj|fuzz-rpgmaker|Debug|Win32;x64;ARM64', + 'fuzz-zanzarah.vcxproj|fuzz-zanzarah|Debug|Win32;x64;ARM64', + 'leak-probe.vcxproj|leak-probe|Release|x64' +) +$actualManifest = @( + $analysisProjects | + ForEach-Object { + '{0}|{1}|{2}|{3}' -f ` + [System.IO.Path]::GetFileName($_.Include), ` + $_.AnalysisReportName, ` + $_.AnalysisConfiguration, ` + $_.AnalysisPlatforms + } +) +Assert-SequenceEqual -Actual $actualManifest -Expected $expectedManifest -Description 'Compiler-analysis project/report manifest' + +$reportNames = @($analysisProjects | ForEach-Object AnalysisReportName) +if (@($reportNames | Sort-Object -Unique).Count -ne $reportNames.Count) { + throw 'Compiler-analysis report names must be globally unique.' +} + +$expectedReportsByPlatform = @{ + Win32 = @( + 'fuzz-pickle.sarif', + 'fuzz-renpy.sarif', + 'fuzz-rpgmaker.sarif', + 'fuzz-zanzarah.sarif', + 'renpy.sarif', + 'rpgmaker.sarif', + 'tests.sarif', + 'zanzarah.sarif' + ) + x64 = @( + 'fuzz-pickle.sarif', + 'fuzz-renpy.sarif', + 'fuzz-rpgmaker.sarif', + 'fuzz-zanzarah.sarif', + 'leak-probe.sarif', + 'renpy.sarif', + 'rpgmaker.sarif', + 'tests.sarif', + 'zanzarah.sarif' + ) + ARM64 = @( + 'fuzz-pickle.sarif', + 'fuzz-renpy.sarif', + 'fuzz-rpgmaker.sarif', + 'fuzz-zanzarah.sarif', + 'renpy.sarif', + 'rpgmaker.sarif', + 'tests.sarif', + 'zanzarah.sarif' + ) +} +foreach ($platform in @('Win32', 'x64', 'ARM64')) { + $actualReports = @( + $analysisProjects | + Where-Object { $platform -in @($_.AnalysisPlatforms -split ';') } | + ForEach-Object { "$($_.AnalysisReportName).sarif" } | + Sort-Object + ) + Assert-SequenceEqual ` + -Actual $actualReports ` + -Expected $expectedReportsByPlatform[$platform] ` + -Description "$platform compiler-analysis report manifest" +} + +$analysisTarget = $aggregateProject.SelectSingleNode('//msb:Target[@Name="RunCompilerAnalysis"]', $namespace) +if ($null -eq $analysisTarget) { + throw 'The aggregate project must expose a RunCompilerAnalysis target.' +} +$activeProject = $analysisTarget.SelectSingleNode('msb:ItemGroup/msb:ActiveAnalysisProject', $namespace) +if ( + $null -eq $activeProject -or + $activeProject.Include -ne '@(AnalysisProject)' -or + $activeProject.Condition -notmatch 'AnalysisPlatforms' -or + $activeProject.Condition -notmatch '\$\(Platform\)' +) { + throw 'RunCompilerAnalysis must filter the explicit manifest by MSBuild platform.' +} +$analysisBuild = $analysisTarget.SelectSingleNode('msb:MSBuild', $namespace) +$expectedProperties = '$(ProjectProperties);Configuration=%(ActiveAnalysisProject.AnalysisConfiguration);ObserverAnalysisReportName=%(ActiveAnalysisProject.AnalysisReportName);ObserverCompileAnalysis=true;ForceRebuild=true' +if ( + $null -eq $analysisBuild -or + $analysisBuild.Projects -ne '@(ActiveAnalysisProject)' -or + $analysisBuild.Targets -ne 'ClCompile' -or + $analysisBuild.BuildInParallel -ne 'true' -or + $analysisBuild.GetAttribute('ContinueOnError') -ne 'ErrorAndContinue' -or + $analysisBuild.Properties -ne $expectedProperties +) { + throw 'RunCompilerAnalysis must analyze every project, retain failures, and avoid linking.' +} + +$expectedPlatformMonikers = @( + "'`$(Platform)' == 'Win32'|x86", + "'`$(Platform)' == 'x64'|x64", + "'`$(Platform)' == 'ARM64'|arm64" +) +$actualPlatformMonikers = @( + $aggregateProject.SelectNodes('//msb:AnalysisPlatformMoniker', $namespace) | + ForEach-Object { "$($_.Condition)|$($_.InnerText)" } +) +Assert-SequenceEqual ` + -Actual $actualPlatformMonikers ` + -Expected $expectedPlatformMonikers ` + -Description 'Compiler-analysis report platform mapping' +$expectedReportProperties = @( + 'AnalysisExpectedReportRoot|$([System.IO.Path]::GetFullPath(''$(MSBuildThisFileDirectory)..\.artifacts\reports\msvc\''))', + 'AnalysisRequestedReportRoot|$([System.IO.Path]::GetFullPath(''$(ObserverAnalysisReportDirectory)\''))', + 'AnalysisReportArchDirectory|$([System.IO.Path]::GetFullPath(''$(AnalysisRequestedReportRoot)$(AnalysisPlatformMoniker)\''))' +) +$actualReportProperties = @( + foreach ($propertyName in @('AnalysisExpectedReportRoot', 'AnalysisRequestedReportRoot', 'AnalysisReportArchDirectory')) { + $property = $aggregateProject.SelectSingleNode("//msb:$propertyName", $namespace) + if ($null -eq $property) { + "${propertyName}|" + } else { + "${propertyName}|$($property.InnerText)" + } + } +) +Assert-SequenceEqual ` + -Actual $actualReportProperties ` + -Expected $expectedReportProperties ` + -Description 'Compiler-analysis report path contract' +$pathValidationError = $analysisTarget.SelectSingleNode('msb:Error[contains(@Condition, "AnalysisExpectedReportRoot")]', $namespace) +$reparseValidationError = $analysisTarget.SelectSingleNode('msb:Error[contains(@Condition, "ReparsePoint")]', $namespace) +$validatedPaths = @( + $analysisTarget.SelectNodes('msb:ItemGroup/msb:AnalysisReportPathToValidate[@Include]', $namespace) | + ForEach-Object Include +) +Assert-SequenceEqual ` + -Actual $validatedPaths ` + -Expected @( + '$(MSBuildThisFileDirectory)..\.artifacts', + '$(MSBuildThisFileDirectory)..\.artifacts\reports', + '$(AnalysisExpectedReportRoot)', + '$(AnalysisReportArchDirectory)' + ) ` + -Description 'Compiler-analysis report paths validated before cleanup' +if ( + $null -eq $pathValidationError -or + $null -eq $reparseValidationError +) { + throw 'RunCompilerAnalysis must reject an unexpected report root and reparse-point cleanup paths.' +} +$pathAttributesUpdate = $analysisTarget.SelectSingleNode( + 'msb:ItemGroup/msb:AnalysisReportPathToValidate[@Update="@(AnalysisReportPathToValidate)"]/msb:PathAttributes', + $namespace +) +if ( + $null -eq $pathAttributesUpdate -or + $pathAttributesUpdate.InnerText -ne "$([char]36)([System.IO.File]::GetAttributes('%(AnalysisReportPathToValidate.FullPath)'))" +) { + throw 'Reparse validation must populate path attributes only after every path item has a full identity.' +} +$staleReports = $analysisTarget.SelectSingleNode('msb:ItemGroup/msb:ExistingAnalysisReport', $namespace) +$deleteReports = $analysisTarget.SelectSingleNode('msb:Delete', $namespace) +if ( + $null -eq $staleReports -or + $staleReports.Include -ne '$(AnalysisReportArchDirectory)*.sarif' -or + $null -eq $deleteReports -or + $deleteReports.Files -ne '@(ExistingAnalysisReport)' +) { + throw 'RunCompilerAnalysis must remove stale architecture SARIF before producing the exact manifest.' +} + +$rebuildTarget = $aggregateProject.SelectSingleNode('//msb:Target[@Name="Rebuild"]', $namespace) +$analysisDispatch = $rebuildTarget.SelectSingleNode('msb:CallTarget[@Targets="RunCompilerAnalysis"]', $namespace) +if ($null -eq $analysisDispatch -or $analysisDispatch.Condition -ne "'`$(ObserverRunCodeAnalysis)' == 'true'") { + throw 'Aggregate Rebuild must dispatch the compiler-analysis graph only when explicitly requested.' +} +$binaryRebuild = $rebuildTarget.SelectSingleNode('msb:MSBuild', $namespace) +if ($null -eq $binaryRebuild -or $binaryRebuild.Condition -ne "'`$(ObserverRunCodeAnalysis)' != 'true'") { + throw 'Ordinary aggregate Rebuild must retain the module/test binary graph.' +} + +$projectProperties = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverProject.props') +$reportNameDefault = $projectProperties.SelectSingleNode('//msb:ObserverAnalysisReportName', $namespace) +if ( + $null -eq $reportNameDefault -or + $reportNameDefault.InnerText -ne '$(ProjectName)' -or + $reportNameDefault.Condition -ne "'`$(ObserverAnalysisReportName)' == ''" +) { + throw 'Analysis reports must default to the unique MSBuild project name.' +} +$prefastLog = $projectProperties.SelectSingleNode('//msb:ClCompile/msb:PREfastLog', $namespace) +if ( + $null -eq $prefastLog -or + $prefastLog.InnerText -ne '$(ObserverAnalysisReportDirectory)\$(PlatformMoniker)\$(ObserverAnalysisReportName).sarif' +) { + throw 'PREfastLog must use the explicit per-project report name.' +} + +$fuzzProperties = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverFuzz.props') +$fuzzValidation = $fuzzProperties.SelectSingleNode('//msb:Target[@Name="ValidateFuzzConfiguration"]/msb:Error', $namespace) +$expectedFuzzCondition = "'`$(ObserverCompileAnalysis)' != 'true' And '`$(Configuration)|`$(Platform)' != 'Fuzz|x64'" +if ($null -eq $fuzzValidation -or $fuzzValidation.Condition -ne $expectedFuzzCondition) { + throw 'Fuzz validation may be bypassed only by the compile-only analysis graph.' +} + +$leakProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\projects\leak-probe.vcxproj') +$leakValidation = $leakProject.SelectSingleNode( + '//msb:Target[@Name="ValidateLeakProbeConfiguration"]/msb:Error', + $namespace +) +if ($null -eq $leakValidation -or $leakValidation.Condition -ne "'`$(Configuration)|`$(Platform)' != 'Release|x64'") { + throw 'Compile analysis must not weaken the leak probe Release|x64 runtime contract.' +} + +Write-Host '[OK] MSVC compile-analysis graph and SARIF manifest cover every first-party target.' diff --git a/build/tests/dynamic-graph-leaves.Tests.ps1 b/build/tests/dynamic-graph-leaves.Tests.ps1 new file mode 100644 index 0000000..949a62b --- /dev/null +++ b/build/tests/dynamic-graph-leaves.Tests.ps1 @@ -0,0 +1,193 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$leafLibrary = Join-Path $repositoryRoot 'build\lib\dynamic-graph-leaves.ps1' +$probeSource = Join-Path $repositoryRoot 'src\tests\leaks\probe.cpp' + +if (-not (Test-Path -LiteralPath $leafLibrary -PathType Leaf)) { + throw "Dynamic graph leaf library is missing: $leafLibrary" +} +. $leafLibrary + +function Assert-Equal { + param( + [Parameter(Mandatory)] $Actual, + [Parameter(Mandatory)] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if ($Actual -cne $Expected) { + throw "$Description expected '$Expected', received '$Actual'." + } +} + +function Assert-Throw { + param( + [Parameter(Mandatory)][scriptblock] $Action, + [Parameter(Mandatory)][string] $Description + ) + + try { + & $Action + } + catch { + return + } + throw "Expected failure: $Description" +} + +$targetSpecs = @(Get-DynamicFuzzTargetSpec) +Assert-Equal -Actual $targetSpecs.Count -Expected 4 -Description 'Fuzz target count' +Assert-Equal -Actual (($targetSpecs.Name | Sort-Object) -join ',') -Expected 'pickle,renpy,rpgmaker,zanzarah' -Description 'Fuzz target names' +Assert-Equal -Actual ($targetSpecs | Where-Object Name -eq 'pickle').MaxLength -Expected 262144 -Description 'Pickle maximum input length' +foreach ($spec in @($targetSpecs | Where-Object Name -ne 'pickle')) { + Assert-Equal -Actual $spec.MaxLength -Expected 1048576 -Description "$($spec.Name) maximum input length" +} +Assert-DynamicRunId -RunId 'local-2026.08.01_01' +Assert-Throw -Description 'unsafe dynamic run id' -Action { + Assert-DynamicRunId -RunId '..\escape' +} + +$replayArguments = Get-DynamicFuzzArgumentList ` + -Phase seed-replay ` + -TargetName pickle ` + -InputPath @('C:\seeds\one', 'C:\seeds\two') ` + -ArtifactDirectory 'C:\artifacts\replay' +Assert-Equal -Actual $replayArguments[0] -Expected 'C:\seeds\one' -Description 'Replay first seed' +Assert-Equal -Actual $replayArguments[1] -Expected 'C:\seeds\two' -Description 'Replay second seed' +if ($replayArguments -notcontains '-max_len=262144' -or $replayArguments -match '^-max_total_time=') { + throw 'Replay arguments do not use libFuzzer individual-file mode with the target maximum length.' +} + +$timedArguments = Get-DynamicFuzzArgumentList ` + -Phase timed ` + -TargetName renpy ` + -InputPath @('C:\corpus\renpy') ` + -ArtifactDirectory 'C:\artifacts\timed' ` + -Seconds 17 +if ($timedArguments -notcontains '-max_total_time=17' -or $timedArguments -notcontains '-use_value_profile=1') { + throw 'Timed fuzz arguments do not enforce the requested duration and value profile.' +} +Assert-Throw -Description 'timed fuzz without a duration' -Action { + Get-DynamicFuzzArgumentList -Phase timed -TargetName renpy -InputPath @('C:\corpus') -ArtifactDirectory 'C:\out' +} + +$temporaryRoot = Join-Path $repositoryRoot ".artifacts\dynamic-leaf-contract-$([guid]::NewGuid().ToString('N'))" +$seedRoot = Join-Path $temporaryRoot 'seeds' +$corpusRoot = Join-Path $temporaryRoot 'corpus' +New-Item -ItemType Directory -Path $seedRoot -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $seedRoot 'hex-seed.hex'), '00 7f FF') +[System.IO.File]::WriteAllBytes((Join-Path $seedRoot 'raw.seed'), [byte[]]@(1, 2, 3)) +try { + [void](Initialize-DynamicFuzzCorpus -SeedDirectory $seedRoot -CorpusDirectory $corpusRoot) + Assert-Equal -Actual ([Convert]::ToHexString([System.IO.File]::ReadAllBytes((Join-Path $corpusRoot 'hex-seed')))) -Expected '007FFF' -Description 'Decoded hex seed' + Assert-Equal -Actual ([Convert]::ToHexString([System.IO.File]::ReadAllBytes((Join-Path $corpusRoot 'raw.seed')))) -Expected '010203' -Description 'Copied raw seed' + [System.IO.File]::WriteAllText((Join-Path $seedRoot 'invalid.hex'), 'ABC') + Assert-Throw -Description 'odd-length hexadecimal seed' -Action { + Initialize-DynamicFuzzCorpus -SeedDirectory $seedRoot -CorpusDirectory (Join-Path $temporaryRoot 'invalid') + } +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} + +$leafRoot = Join-Path $repositoryRoot ".artifacts\dynamic-leaf-invoke-$([guid]::NewGuid().ToString('N'))" +$leafSeeds = Join-Path $leafRoot 'seeds' +$fakeFuzzer = Join-Path $leafRoot 'fuzz-pickle.exe' +$fakeProbe = Join-Path $leafRoot 'leak-probe.exe' +New-Item -ItemType Directory -Path $leafSeeds -Force | Out-Null +[System.IO.File]::WriteAllBytes((Join-Path $leafSeeds 'none.pickle'), [byte[]]@([char]'N', [char]'.')) +[System.IO.File]::WriteAllText($fakeFuzzer, 'fake') +[System.IO.File]::WriteAllText($fakeProbe, 'fake') +try { + $fuzzInvocations = [System.Collections.Generic.List[object]]::new() + $fuzzInvoker = { + param($FilePath, $Arguments, $WorkingDirectory, $LogPath) + $fuzzInvocations.Add([pscustomobject]@{ + FilePath = $FilePath + Arguments = @($Arguments) + WorkingDirectory = $WorkingDirectory + LogPath = $LogPath + }) + return 0 + }.GetNewClosure() + $fuzzEvidence = Invoke-DynamicFuzzLeaf ` + -Phase seed-replay ` + -TargetName pickle ` + -FuzzerPath $fakeFuzzer ` + -SeedDirectory $leafSeeds ` + -CorpusDirectory (Join-Path $leafRoot 'persistent-corpus') ` + -RunDirectory (Join-Path $leafRoot 'replay-run') ` + -WorkingDirectory $leafRoot ` + -Seconds 9 ` + -NativeInvoker $fuzzInvoker + Assert-Equal -Actual $fuzzInvocations.Count -Expected 1 -Description 'Fuzz leaf native invocation count' + Assert-Equal -Actual $fuzzEvidence.phase -Expected 'seed-replay' -Description 'Fuzz leaf evidence phase' + if ($fuzzInvocations[0].Arguments -notcontains '-max_len=262144') { + throw 'Fuzz leaf did not pass the selected target bound to its native adapter.' + } + if (-not (Test-Path -LiteralPath (Join-Path $leafRoot 'replay-run\result.json') -PathType Leaf)) { + throw 'Fuzz leaf did not write its isolated evidence file.' + } + + $probeCapture = { + param($FilePath, $Arguments, $WorkingDirectory) + $null = $FilePath, $WorkingDirectory + if ($Arguments -notcontains '--scenario' -or $Arguments -notcontains 'malformed') { + throw 'Leak preflight adapter did not receive the exact scenario.' + } + return @( + 'OBSERVER_LEAK_PROBE|READY|pid=77|mode=lifecycle|configuration=Release|scenarios=malformed', + 'OBSERVER_LEAK_PROBE|SNAPSHOT|baseline|pid=77|completed_operations=1', + 'OBSERVER_LEAK_PROBE|DONE|pid=77|completed_operations=4' + ) + } + $leakEvidencePath = Join-Path $leafRoot 'leak-preflight.json' + $leakEvidence = Invoke-DynamicLeakPreflightLeaf ` + -ProbePath $fakeProbe ` + -BinaryDirectory $leafRoot ` + -Mode lifecycle ` + -Scenario malformed ` + -EvidencePath $leakEvidencePath ` + -NativeCapture $probeCapture + Assert-Equal -Actual $leakEvidence.processId -Expected 77 -Description 'Leak preflight evidence process ID' + if (-not (Test-Path -LiteralPath $leakEvidencePath -PathType Leaf)) { + throw 'Leak preflight did not write its isolated evidence file.' + } +} +finally { + if (Test-Path -LiteralPath $leafRoot) { + Remove-Item -LiteralPath $leafRoot -Recurse -Force + } +} + +$scenarioNames = @(Get-DynamicLeakScenarioName) +Assert-Equal -Actual ($scenarioNames -join ',') -Expected 'small-success,malformed,cancellation,read-failure,write-failure,large-metadata,sparse-metadata' -Description 'Leak scenarios' +$probeArguments = Get-DynamicLeakProbeArgumentList ` + -Mode lifecycle ` + -Scenario sparse-metadata ` + -Warmup 2 ` + -Iterations 3 ` + -Windows 4 +Assert-Equal -Actual ($probeArguments -join ' ') -Expected '--mode lifecycle --scenario sparse-metadata --warmup 2 --iterations 3 --windows 4' -Description 'Leak probe arguments' + +$ready = 'OBSERVER_LEAK_PROBE|READY|pid=42|mode=operations|configuration=Release|scenarios=read-failure' +$readyEvidence = Assert-DynamicLeakReadyMarker -Line $ready -Mode operations -Scenario read-failure +Assert-Equal -Actual $readyEvidence.ProcessId -Expected 42 -Description 'Leak READY process ID' +Assert-Throw -Description 'READY marker for another scenario' -Action { + Assert-DynamicLeakReadyMarker -Line $ready -Mode operations -Scenario malformed +} + +$probeText = Get-Content -Raw -LiteralPath $probeSource +foreach ($requiredProbeContract in @('--scenario', 'settings.scenario', 'selected_scenario')) { + if (-not $probeText.Contains($requiredProbeContract, [System.StringComparison]::Ordinal)) { + throw "Leak probe source is missing exact scenario selection contract: $requiredProbeContract" + } +} + +Write-Output '[OK] Dynamic graph leaf contracts passed.' diff --git a/build/tests/graph-leaves.Tests.ps1 b/build/tests/graph-leaves.Tests.ps1 new file mode 100644 index 0000000..57a7cca --- /dev/null +++ b/build/tests/graph-leaves.Tests.ps1 @@ -0,0 +1,463 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +. (Join-Path $repositoryRoot 'build\lib\analysis-reporting.ps1') +. (Join-Path $repositoryRoot 'build\lib\graph-leaves.ps1') + +function Assert-Equal { + param( + [Parameter(Mandatory)][AllowNull()] $Actual, + [Parameter(Mandatory)][AllowNull()] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if (-not [object]::Equals($Actual, $Expected)) { + throw "${Description}: actual='$Actual', expected='$Expected'." + } +} + +function Assert-ScriptFailure { + param( + [Parameter(Mandatory)][scriptblock] $Action, + [Parameter(Mandatory)][string] $Pattern, + [Parameter(Mandatory)][string] $Description + ) + + try { + & $Action + } catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "${Description}: unexpected error '$($_.Exception.Message)'." + } + return + } + throw "${Description}: expected an error matching '$Pattern'." +} + +$buildRequest = Get-GraphBuildProjectRequest ` + -RepositoryRoot $repositoryRoot ` + -Architecture 'x64' ` + -Configuration 'Debug' ` + -Project 'renpy' +Assert-Equal -Actual $buildRequest.Target -Expected 'Build' -Description 'Native project target' +Assert-Equal -Actual $buildRequest.Platform -Expected 'x64' -Description 'Native project platform' +Assert-Equal ` + -Actual $buildRequest.ProjectPath ` + -Expected (Join-Path $repositoryRoot 'build\projects\renpy.vcxproj') ` + -Description 'Allowlisted native project path' + +Assert-ScriptFailure ` + -Action { Get-GraphBuildProjectRequest -RepositoryRoot $repositoryRoot -Architecture x64 -Configuration Debug -Project '..\renpy' } ` + -Pattern 'not allowlisted' ` + -Description 'Project traversal rejection' + +$selectedSource = 'src/core/io/bounded_stream.cpp' +$unitSlug = Get-GraphTranslationUnitSlug -Source $selectedSource +Assert-Equal -Actual $unitSlug -Expected 'core-io-bounded-stream-bc188848' -Description 'Stable TU slug' + +$tidyRequest = Get-GraphAnalysisRequest ` + -RepositoryRoot $repositoryRoot ` + -Architecture 'x64' ` + -Backend 'clang-tidy' ` + -Project 'renpy' ` + -SelectedFile $selectedSource ` + -Unit $unitSlug +Assert-Equal -Actual $tidyRequest.Target -Expected 'ClCompile' -Description 'Selected-file target' +Assert-Equal -Actual $tidyRequest.Configuration -Expected 'Debug' -Description 'Selected-file configuration' +Assert-Equal ` + -Actual $tidyRequest.Properties.SelectedFiles ` + -Expected (Join-Path $repositoryRoot 'src\core\io\bounded_stream.cpp') ` + -Description 'SelectedFiles exact MSBuild seam' +Assert-Equal -Actual $tidyRequest.Properties.SelectedFilesBuildPCH -Expected 'false' -Description 'Selected-file PCH isolation' +Assert-Equal -Actual $tidyRequest.Properties.SelectedFilesBuildModules -Expected 'false' -Description 'Selected-file module isolation' +Assert-Equal -Actual $tidyRequest.Properties.EnableMicrosoftCodeAnalysis -Expected 'false' -Description 'Tidy excludes PREfast' +Assert-Equal -Actual $tidyRequest.Properties.ObserverEnableClangTidy -Expected 'true' -Description 'Tidy enabled' +Assert-Equal ` + -Actual $tidyRequest.Properties.IntDir ` + -Expected (Join-Path $repositoryRoot ".artifacts\analysis\clang-tidy\x64\renpy\$unitSlug\obj\") ` + -Description 'Tidy isolated IntDir' + +Assert-ScriptFailure ` + -Action { + Get-GraphAnalysisRequest ` + -RepositoryRoot $repositoryRoot ` + -Architecture x64 ` + -Backend clang-tidy ` + -Project renpy ` + -SelectedFile src/tests/main.cpp ` + -Unit tests-main + } ` + -Pattern 'not a ClCompile Include item' ` + -Description 'Cross-project selected file rejection' + +$msvcSelectedSource = 'src/modules/renpy/pickle.cpp' +$msvcUnitSlug = Get-GraphTranslationUnitSlug -Source $msvcSelectedSource +$msvcRequest = Get-GraphAnalysisRequest ` + -RepositoryRoot $repositoryRoot ` + -Architecture 'x64' ` + -Backend 'msvc' ` + -Project 'renpy' ` + -SelectedFile $msvcSelectedSource ` + -Unit $msvcUnitSlug +Assert-Equal -Actual $msvcRequest.Configuration -Expected 'Debug' -Description 'MSVC unit analysis configuration' +Assert-Equal -Actual $msvcRequest.Properties.EnableMicrosoftCodeAnalysis -Expected 'true' -Description 'PREfast enabled' +Assert-Equal -Actual $msvcRequest.Properties.ObserverEnableClangTidy -Expected 'false' -Description 'PREfast excludes tidy' +Assert-Equal ` + -Actual $msvcRequest.Properties.SelectedFiles ` + -Expected (Join-Path $repositoryRoot 'src\modules\renpy\pickle.cpp') ` + -Description 'PREfast SelectedFiles exact seam' +Assert-Equal ` + -Actual $msvcRequest.Properties.IntDir ` + -Expected (Join-Path $repositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\obj\") ` + -Description 'PREfast isolated IntDir' +Assert-Equal ` + -Actual $msvcRequest.Properties.ObserverAnalysisReportPath ` + -Expected (Join-Path $repositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\renpy.sarif") ` + -Description 'PREfast isolated raw report' + +$leakSelectedSource = 'src/tests/leaks/probe.cpp' +$leakUnitSlug = Get-GraphTranslationUnitSlug -Source $leakSelectedSource +$leakMsvcRequest = Get-GraphAnalysisRequest ` + -RepositoryRoot $repositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -Project leak-probe ` + -SelectedFile $leakSelectedSource ` + -Unit $leakUnitSlug +Assert-Equal -Actual $leakMsvcRequest.Configuration -Expected 'Release' -Description 'Leak unit analysis configuration' + +Assert-ScriptFailure ` + -Action { Get-GraphAnalysisRequest -RepositoryRoot $repositoryRoot -Architecture x64 -Backend msvc -Project renpy } ` + -Pattern 'requires SelectedFile and Unit' ` + -Description 'Project-wide PREfast rejection' + +[xml] $projectProperties = Get-Content -Raw -LiteralPath (Join-Path $repositoryRoot 'build\ObserverProject.props') +$namespace = [System.Xml.XmlNamespaceManager]::new($projectProperties.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$isolatedPrefastLog = $projectProperties.SelectSingleNode( + '//msb:ClCompile/msb:PREfastLog[contains(@Condition, "ObserverAnalysisReportPath")]', + $namespace +) +if ($null -eq $isolatedPrefastLog -or $isolatedPrefastLog.InnerText -ne '$(ObserverAnalysisReportPath)') { + throw 'ObserverProject.props must honor the isolated per-analysis-node PREfast path.' +} + +$testRoot = Join-Path $repositoryRoot ".artifacts\contract-tests\graph-leaves-$([guid]::NewGuid().ToString('N'))" +try { + $artifactJunction = Join-Path $testRoot 'artifact-junction' + New-Item -ItemType Directory -Force -Path $testRoot | Out-Null + New-Item -ItemType Junction -Path $artifactJunction -Target (Join-Path $repositoryRoot 'src') | Out-Null + try { + Assert-ScriptFailure ` + -Action { Resolve-GraphArtifactPath -RepositoryRoot $repositoryRoot -Path $artifactJunction } ` + -Pattern 'reparse point' ` + -Description 'Artifact leaf reparse-point rejection' + } finally { + $junctionItem = Get-Item -Force -LiteralPath $artifactJunction + if (($junctionItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { + throw "Refusing to remove a non-reparse test path: $artifactJunction" + } + Remove-Item -Force -LiteralPath $artifactJunction + } + + $sarifRepositoryRoot = Join-Path $testRoot 'repository' + $projectRoot = Join-Path $sarifRepositoryRoot 'build\projects' + New-Item -ItemType Directory -Force -Path $projectRoot | Out-Null + @' + + + + + + +'@ | Set-Content -LiteralPath (Join-Path $projectRoot 'renpy.vcxproj') -Encoding utf8 + @' + + + + + +'@ | Set-Content -LiteralPath (Join-Path $projectRoot 'rpgmaker.vcxproj') -Encoding utf8 + + $msvcUnitRoot = Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x64\units' + New-Item -ItemType Directory -Force -Path $msvcUnitRoot | Out-Null + $rpgmakerUnitSlug = Get-GraphTranslationUnitSlug -Source 'src/modules/rpgmaker/rpgmaker.cpp' + Assert-ScriptFailure ` + -Action { + Assert-GraphProjectUnit ` + -RepositoryRoot $sarifRepositoryRoot ` + -Project renpy ` + -Unit $unitSlug.ToUpperInvariant() + } ` + -Pattern 'not a ClCompile unit' ` + -Description 'Translation-unit slug case rejection' + $convertedMsvcReports = [System.Collections.Generic.List[string]]::new() + foreach ($msvcCase in @( + [pscustomobject]@{ Project = 'renpy'; Unit = $msvcUnitSlug }, + [pscustomobject]@{ Project = 'renpy'; Unit = 'core-io-bounded-stream-bc188848' } + )) { + $rawMsvc = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\$($msvcCase.Project)\$($msvcCase.Unit)\$($msvcCase.Project).sarif" + New-Item -ItemType Directory -Force -Path (Split-Path $rawMsvc -Parent) | Out-Null + [ordered]@{ + version = '2.1.0' + runs = @( + [ordered]@{ + tool = [ordered]@{ driver = [ordered]@{ name = 'PREfast' } } + results = @() + } + ) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $rawMsvc -Encoding utf8 + $convertedMsvc = Join-Path $msvcUnitRoot "$($msvcCase.Project)-$($msvcCase.Unit).sarif" + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $rawMsvc ` + -OutputPath $convertedMsvc ` + -Architecture x64 ` + -Project $msvcCase.Project ` + -Unit $msvcCase.Unit + $convertedMsvcReports.Add($convertedMsvc) + } + $convertedMsvcMerged = Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x64\msvc-analyze.sarif' + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths $convertedMsvcReports.ToArray() ` + -OutputPath $convertedMsvcMerged + $convertedMsvcResult = Get-Content -Raw -LiteralPath $convertedMsvcMerged | ConvertFrom-Json + Assert-Equal -Actual @($convertedMsvcResult.runs).Count -Expected 2 -Description 'MSVC unit SARIF identities are mergeable' + + $tidyUnitRoot = Join-Path $sarifRepositoryRoot '.artifacts\reports\clang-tidy\x64\units' + New-Item -ItemType Directory -Force -Path $tidyUnitRoot | Out-Null + $tidyReports = [System.Collections.Generic.List[string]]::new() + foreach ($tidyCase in @( + [pscustomobject]@{ Project = 'renpy'; Unit = 'core-io-bounded-stream-bc188848' }, + [pscustomobject]@{ Project = 'rpgmaker'; Unit = $rpgmakerUnitSlug } + )) { + $tidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\$($tidyCase.Project)\$($tidyCase.Unit)\obj" + New-Item -ItemType Directory -Force -Path $tidyObjectRoot | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $tidyObjectRoot "$($tidyCase.Project).ClangTidy.log") | Out-Null + $tidyReport = Join-Path $tidyUnitRoot "$($tidyCase.Project)-$($tidyCase.Unit).sarif" + Convert-GraphClangTidyUnitSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -ObjectRoot $tidyObjectRoot ` + -OutputPath $tidyReport ` + -Architecture x64 ` + -Project $tidyCase.Project ` + -Unit $tidyCase.Unit + $tidyReports.Add($tidyReport) + } + $tidyMergedPath = Join-Path $sarifRepositoryRoot '.artifacts\reports\clang-tidy\x64\clang-tidy.sarif' + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend clang-tidy ` + -InputPaths $tidyReports.ToArray() ` + -OutputPath $tidyMergedPath + $tidyMerged = Get-Content -Raw -LiteralPath $tidyMergedPath | ConvertFrom-Json + Assert-Equal -Actual @($tidyMerged.runs).Count -Expected 2 -Description 'Tidy unit SARIF identities are mergeable' + if ($tidyMerged.runs[0].automationDetails.id -eq $tidyMerged.runs[1].automationDetails.id) { + throw 'Tidy unit SARIF identities must be unique.' + } + + $foreignUnit = $rpgmakerUnitSlug + $foreignRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$foreignUnit\renpy.sarif" + New-Item -ItemType Directory -Force -Path (Split-Path $foreignRaw -Parent) | Out-Null + Copy-Item -LiteralPath $convertedMsvcReports[0] -Destination $foreignRaw + Assert-ScriptFailure ` + -Action { + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $foreignRaw ` + -OutputPath (Join-Path $msvcUnitRoot "renpy-$foreignUnit.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $foreignUnit + } ` + -Pattern 'not a ClCompile unit' ` + -Description 'Regex-shaped foreign unit rejection' + + $wrongVersionRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\renpy.sarif" + New-Item -ItemType Directory -Force -Path (Split-Path $wrongVersionRaw -Parent) | Out-Null + [ordered]@{ version = '2.0.0'; runs = @([ordered]@{}) } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $wrongVersionRaw -Encoding utf8 + Assert-ScriptFailure ` + -Action { + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $wrongVersionRaw ` + -OutputPath (Join-Path $msvcUnitRoot "renpy-$msvcUnitSlug.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $msvcUnitSlug + } ` + -Pattern 'version.*2\.1\.0' ` + -Description 'Normalized SARIF version rejection' + + $emptyRunsRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$unitSlug\renpy.sarif" + New-Item -ItemType Directory -Force -Path (Split-Path $emptyRunsRaw -Parent) | Out-Null + [ordered]@{ version = '2.1.0'; runs = @() } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $emptyRunsRaw -Encoding utf8 + Assert-ScriptFailure ` + -Action { + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $emptyRunsRaw ` + -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $unitSlug + } ` + -Pattern 'runs.*non-empty list of objects' ` + -Description 'Normalized SARIF empty runs rejection' + + [ordered]@{ version = '2.1.0'; runs = @('not-an-object') } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $emptyRunsRaw -Encoding utf8 + Assert-ScriptFailure ` + -Action { + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $emptyRunsRaw ` + -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $unitSlug + } ` + -Pattern 'runs.*non-empty list of objects' ` + -Description 'Normalized SARIF non-object run rejection' + + $crossRootRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x86\renpy\$unitSlug\renpy.sarif" + New-Item -ItemType Directory -Force -Path (Split-Path $crossRootRaw -Parent) | Out-Null + Copy-Item -LiteralPath $convertedMsvcReports[0] -Destination $crossRootRaw + Assert-ScriptFailure ` + -Action { + Convert-GraphMsvcSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -InputPath $crossRootRaw ` + -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $unitSlug + } ` + -Pattern 'expected input path' ` + -Description 'MSVC cross-architecture input rejection' + + $wrongTidyOutput = Join-Path $sarifRepositoryRoot ".artifacts\reports\clang-tidy\x64\units\rpgmaker-$unitSlug.sarif" + $validTidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\renpy\$unitSlug\obj" + New-Item -ItemType Directory -Force -Path $validTidyObjectRoot | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $validTidyObjectRoot 'renpy.ClangTidy.log') | Out-Null + Assert-ScriptFailure ` + -Action { + Convert-GraphClangTidyUnitSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -ObjectRoot $validTidyObjectRoot ` + -OutputPath $wrongTidyOutput ` + -Architecture x64 ` + -Project renpy ` + -Unit $unitSlug + } ` + -Pattern 'expected output path' ` + -Description 'Tidy project relabeling rejection' + + $wrongTidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\rpgmaker\$unitSlug\obj" + New-Item -ItemType Directory -Force -Path $wrongTidyObjectRoot | Out-Null + Assert-ScriptFailure ` + -Action { + Convert-GraphClangTidyUnitSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -ObjectRoot $wrongTidyObjectRoot ` + -OutputPath (Join-Path $tidyUnitRoot "renpy-$unitSlug.sarif") ` + -Architecture x64 ` + -Project renpy ` + -Unit $unitSlug + } ` + -Pattern 'expected object root path' ` + -Description 'Tidy object-root relabeling rejection' + + $emptyMergeInput = Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif" + [ordered]@{ version = '2.1.0'; runs = @() } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $emptyMergeInput -Encoding utf8 + Assert-ScriptFailure ` + -Action { + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths @($emptyMergeInput) ` + -OutputPath $convertedMsvcMerged + } ` + -Pattern 'runs.*non-empty list of objects' ` + -Description 'Zero-run merge input rejection' + + [ordered]@{ + version = '2.1.0' + runs = @([ordered]@{ automationDetails = [ordered]@{ id = "msvc-analyze/x64/rpgmaker/$foreignUnit/" } }) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $emptyMergeInput -Encoding utf8 + Assert-ScriptFailure ` + -Action { + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths @($emptyMergeInput) ` + -OutputPath $convertedMsvcMerged + } ` + -Pattern 'expected unit identities' ` + -Description 'Merge relabeled identity rejection' + + $crossBackendMergeInput = Join-Path $tidyUnitRoot "renpy-$unitSlug.sarif" + Copy-Item -LiteralPath $emptyMergeInput -Destination $crossBackendMergeInput -Force + Assert-ScriptFailure ` + -Action { + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths @($crossBackendMergeInput) ` + -OutputPath $convertedMsvcMerged + } ` + -Pattern 'expected input path' ` + -Description 'Merge cross-backend input rejection' + + Assert-ScriptFailure ` + -Action { + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths @($emptyMergeInput) ` + -OutputPath (Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x86\msvc-analyze.sarif') + } ` + -Pattern 'expected output path' ` + -Description 'Merge cross-architecture output rejection' + + Assert-ScriptFailure ` + -Action { + Merge-GraphSarif ` + -RepositoryRoot $sarifRepositoryRoot ` + -Architecture x64 ` + -Backend msvc ` + -InputPaths @((Join-Path $msvcUnitRoot 'renpy-missing-12345678.sarif')) ` + -OutputPath $convertedMsvcMerged + } ` + -Pattern 'was not found' ` + -Description 'Missing unit SARIF rejection' +} finally { + if (Test-Path -LiteralPath $testRoot) { + $resolvedTestRoot = [System.IO.Path]::GetFullPath($testRoot) + $expectedParent = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts\contract-tests')) + if (-not $resolvedTestRoot.StartsWith($expectedParent + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove unexpected contract-test path: $resolvedTestRoot" + } + Remove-Item -Recurse -Force -LiteralPath $resolvedTestRoot + } +} + +Write-Output '[OK] Native graph leaves isolate selected-file analysis and deterministic SARIF fan-in.' diff --git a/build/tests/leak-release-contract.Tests.ps1 b/build/tests/leak-release-contract.Tests.ps1 new file mode 100644 index 0000000..4cb371c --- /dev/null +++ b/build/tests/leak-release-contract.Tests.ps1 @@ -0,0 +1,86 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$namespace = [System.Xml.XmlNamespaceManager]::new([System.Xml.NameTable]::new()) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + +function Read-MSBuildProject { + param([Parameter(Mandatory)][string] $Path) + + [xml] $project = Get-Content -Raw -LiteralPath $Path + return $project +} + +function Assert-ContainsExactly { + param( + [Parameter(Mandatory)][string[]] $Actual, + [Parameter(Mandatory)][string[]] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + $difference = @(Compare-Object -ReferenceObject $Expected -DifferenceObject $Actual) + if ($difference.Count -ne 0) { + throw "$Description differs from the required Release contract." + } +} + +$probeProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\projects\leak-probe.vcxproj') +$runtimeLibraries = @( + $probeProject.SelectNodes('//msb:RuntimeLibrary', $namespace) | + ForEach-Object InnerText | + Sort-Object -Unique +) +Assert-ContainsExactly -Actual $runtimeLibraries -Expected @('MultiThreaded') -Description 'Leak probe runtime library' + +$dependencies = @( + $probeProject.SelectNodes('//msb:AdditionalDependencies', $namespace) | + ForEach-Object InnerText +) +if ($dependencies -notcontains 'zs.lib;%(AdditionalDependencies)') { + throw 'Leak probe must link the Release zlib library.' +} +if ($dependencies -match 'zsd\.lib') { + throw 'Leak probe must not link a Debug zlib library.' +} + +$validationError = $probeProject.SelectSingleNode( + '//msb:Target[@Name="ValidateLeakProbeConfiguration"]/msb:Error', + $namespace +) +if ($null -eq $validationError -or $validationError.Condition -notmatch 'Release\|x64') { + throw 'Leak probe validation must accept only Release|x64.' +} + +$aggregateProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverModules.proj') +$aggregateValidation = $aggregateProject.SelectSingleNode( + '//msb:Target[@Name="BuildLeakProbe"]/msb:Error', + $namespace +) +if ($null -eq $aggregateValidation -or $aggregateValidation.Condition -notmatch 'Release\|x64') { + throw 'BuildLeakProbe must accept only Release|x64.' +} + +$orchestrationFiles = @( + Get-Item -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') + Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name +) +foreach ($requiredPattern in @( + "Invoke-MSBuild -Target 'BuildLeakProbe' -Architecture 'x64' -Configuration 'Release'", + "Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release'", + "Assert-ReleaseBinary -Architecture 'x64'" + )) { + $matchingFiles = @( + $orchestrationFiles | + Where-Object { + (Get-Content -Raw -LiteralPath $_.FullName).Contains($requiredPattern, [StringComparison]::Ordinal) + } + ) + if ($matchingFiles.Count -ne 1) { + throw "Leak orchestration is missing the Release evidence step: $requiredPattern" + } +} + +Write-Host '[OK] Leak probe is constrained to the shipping x64 Release /MT configuration.' diff --git a/build/tests/mutation-ci-contract.Tests.ps1 b/build/tests/mutation-ci-contract.Tests.ps1 new file mode 100644 index 0000000..0d84b0f --- /dev/null +++ b/build/tests/mutation-ci-contract.Tests.ps1 @@ -0,0 +1,128 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$workflowPath = Join-Path $repositoryRoot '.github\workflows\mutation.yml' +$runnerPath = Join-Path $repositoryRoot 'build\mutation\run.sh' +$configPath = Join-Path $repositoryRoot 'build\mutation\mull.yml' +$mainPath = Join-Path $repositoryRoot 'src\tests\mutation\main.cpp' + +foreach ($requiredPath in @($workflowPath, $runnerPath, $configPath, $mainPath)) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "Mutation CI contract file is missing: $requiredPath" + } +} + +$workflow = Get-Content -Raw -LiteralPath $workflowPath +$runner = Get-Content -Raw -LiteralPath $runnerPath +$config = Get-Content -Raw -LiteralPath $configPath +$portableMain = Get-Content -Raw -LiteralPath $mainPath + +foreach ($trigger in @('push:', 'pull_request:', 'schedule:', 'workflow_dispatch:')) { + if ($workflow -notmatch [regex]::Escape($trigger)) { + throw "Mutation workflow must expose the '$trigger' trigger." + } +} +if ($workflow -notmatch 'runs-on:\s*ubuntu-24\.04') { + throw 'Mutation testing must run on the pinned Ubuntu 24.04 image.' +} +if ($workflow -notmatch '(?ms)^permissions:\s*\r?\n\s+contents:\s*read\s*$') { + throw 'Mutation workflow must declare least-privilege contents:read permissions.' +} +if ($workflow -match 'security-events:\s*write|checks:\s*write|contents:\s*write') { + throw 'Mutation workflow must not request write permissions.' +} + +$expectedPins = @( + "MULL_VERSION: '0.34.0'", + "MULL_LLVM: '19'", + "VCPKG_COMMIT: '9e593bb18ea69cc5095e012465dcd675a822ed0d'", + "MULL_REPOSITORY_FINGERPRINT: '6975C1E8A078A727081F4B7541DB35380DE6BD6F'" +) +foreach ($pin in $expectedPins) { + if (-not $workflow.Contains($pin)) { + throw "Mutation workflow is missing the exact dependency pin: $pin" + } +} +if ($workflow -match '(?m)curl[^\r\n]*\|\s*(sudo\s+)?(bash|sh)') { + throw 'Mutation workflow must not pipe downloaded content into a shell.' +} +if ($workflow -notmatch 'gpg\s+--show-keys\s+--with-colons' -or $workflow -notmatch 'MULL_REPOSITORY_FINGERPRINT') { + throw 'Mutation workflow must verify the repository signing-key fingerprint before installation.' +} +if ($workflow -notmatch 'mull-\$\{MULL_LLVM\}=\$\{MULL_VERSION\}') { + throw 'Mutation workflow must install the exact Mull package version.' +} +if ($workflow -notmatch 'repository:\s*microsoft/vcpkg' -or $workflow -notmatch 'ref:\s*\$\{\{ env\.VCPKG_COMMIT \}\}') { + throw 'Mutation workflow must obtain vcpkg at the manifest baseline commit.' +} +if ( + -not $workflow.Contains("- 'build/tests/test_mutation_report.py'") -or + -not $workflow.Contains('python3 -m unittest build.tests.test_mutation_report -v') +) { + throw 'Mutation workflow must run the report-validator regression suite when that suite changes.' +} + +if ($workflow -notmatch '(?ms)id:\s*mutation.*?continue-on-error:\s*true') { + throw 'Mutation execution must retain control so reports can be archived on failure.' +} +if ( + -not $workflow.Contains('mkdir -p .artifacts/reports/mutation') -or + -not $workflow.Contains('> .artifacts/reports/mutation/ci.log 2>&1') +) { + throw 'Verbose mutation output must be retained as evidence, including infrastructure failures.' +} +if ($workflow -notmatch '(?ms)if:\s*always\(\).*?uses:\s*actions/upload-artifact@v7') { + throw 'Mutation evidence must always be archived.' +} +if ($workflow -notmatch '(?ms)if:\s*steps\.mutation\.outcome\s*==\s*''failure''.*?exit\s+1') { + throw 'Surviving mutants or mutation infrastructure failures must fail the workflow after archival.' +} + +if ($config -notmatch '(?m)^\s*-\s+cxx_default\s*$') { + throw 'The initial mutation scope must enable the stable cxx_default operator group.' +} +if ($config -notmatch 'src/modules/renpy/pickle\\\.cpp\$') { + throw 'The Mull include path must be confined to the portable first-party pickle implementation.' +} +if ($config -match 'includeNotCovered:\s*true|dryRunEnabled:\s*true') { + throw 'Mutation configuration must execute only reached mutants, never dry-run them.' +} + +foreach ($source in @( + 'src/modules/renpy/pickle.cpp', + 'src/tests/unit/pickle.cpp', + 'src/tests/mutation/main.cpp' +)) { + if (-not $runner.Contains($source)) { + throw "Portable mutation build must compile $source." + } +} +foreach ($requiredArgument in @( + '-fpass-plugin=/usr/lib/mull-ir-frontend-${MULL_LLVM}', + '--reporters Elements', + '--mutation-score-threshold 100', + '--strict', + '--no-output', + 'validate_report.py' +)) { + if (-not $runner.Contains($requiredArgument)) { + throw "Mutation runner is missing required argument: $requiredArgument" + } +} +if ($runner -notmatch '(?m)^set -euo pipefail$') { + throw 'Mutation runner must fail closed on command, variable, and pipeline errors.' +} +if ($runner -match '(?i)cmake|msbuild|\.\s*[/\\]build\.ps1') { + throw 'Portable mutation testing must not enter the Windows/MSBuild production graph.' +} +if ($runner -notmatch '(?m)^"\$\{test_binary\}"$') { + throw 'The unmutated portable test binary must pass before Mull runs.' +} +if ($portableMain -notmatch 'Catch::Session\(\)\.run\(argc, argv\)') { + throw 'The portable test entry point must run the existing Catch2 tests without Windows setup.' +} + +Write-Output 'Mutation CI static contract is valid.' diff --git a/build/tests/package-manifest.Tests.ps1 b/build/tests/package-manifest.Tests.ps1 new file mode 100644 index 0000000..cca25fa --- /dev/null +++ b/build/tests/package-manifest.Tests.ps1 @@ -0,0 +1,291 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$validatorPath = Join-Path $repositoryRoot 'build\lib\package-manifest.ps1' +if (-not (Test-Path -LiteralPath $validatorPath -PathType Leaf)) { + throw "Package manifest validator is missing: $validatorPath" +} +. $validatorPath + +$moduleEntries = @{ + renpy = @( + 'renpy.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/rpatool.txt' + 'docs/thirdparty/serde-pickle.txt' + 'docs/thirdparty/zlib.txt' + ) + rpgmaker = @( + 'rpgmaker.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/rgssad.txt' + ) + zanzarah = @( + 'zanzarah.so' + 'observer_user.ini' + 'docs/license.txt' + 'docs/thirdparty/Observer.txt' + 'docs/thirdparty/zanzapak.txt' + ) +} +$symbolsEntries = @('renpy.pdb', 'rpgmaker.pdb', 'zanzarah.pdb') +$assertionCount = 0 + +function Write-TestZip { + param( + [Parameter(Mandatory)][string] $Directory, + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Entries + ) + + $archivePath = Join-Path $Directory $Name + $fileStream = [System.IO.File]::Open( + $archivePath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + try { + $archive = [System.IO.Compression.ZipArchive]::new( + $fileStream, + [System.IO.Compression.ZipArchiveMode]::Create, + $true + ) + try { + foreach ($entryName in $Entries) { + $entry = $archive.CreateEntry($entryName, [System.IO.Compression.CompressionLevel]::NoCompression) + if ($entryName.EndsWith('/', [System.StringComparison]::Ordinal)) { + continue + } + + $entryStream = $entry.Open() + try { + $content = [System.Text.Encoding]::UTF8.GetBytes("fixture:$entryName") + $entryStream.Write($content, 0, $content.Length) + } + finally { + $entryStream.Dispose() + } + } + } + finally { + $archive.Dispose() + } + } + finally { + $fileStream.Dispose() + } + + return $archivePath +} + +function Assert-Success { + param( + [Parameter(Mandatory)][string] $Description, + [Parameter(Mandatory)][scriptblock] $Action + ) + + try { + & $Action | Out-Null + } + catch { + throw "$Description should succeed, but failed: $($_.Exception.Message)" + } + $script:assertionCount++ +} + +function Assert-Rejected { + param( + [Parameter(Mandatory)][string] $Description, + [Parameter(Mandatory)][string] $MessageFragment, + [Parameter(Mandatory)][scriptblock] $Action + ) + + $caught = $false + try { + & $Action | Out-Null + } + catch { + $caught = $true + if (-not $_.Exception.Message.Contains($MessageFragment, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$Description failed for the wrong reason: $($_.Exception.Message)" + } + } + if (-not $caught) { + throw "$Description should have been rejected." + } + $script:assertionCount++ +} + +$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts')) +New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null +$temporaryRoot = [System.IO.Path]::GetFullPath( + (Join-Path $artifactsRoot "package-manifest-tests-$([guid]::NewGuid().ToString('N'))") +) +$requiredPrefix = $artifactsRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar +) + [System.IO.Path]::DirectorySeparatorChar +if (-not $temporaryRoot.StartsWith($requiredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to create package test data outside .artifacts: $temporaryRoot" +} +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + +try { + foreach ($moduleName in @('renpy', 'rpgmaker', 'zanzarah')) { + $archivePath = Write-TestZip -Directory $temporaryRoot -Name "$moduleName-valid.zip" -Entries $moduleEntries[$moduleName] + Assert-Success "$moduleName exact manifest" { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName $moduleName + } + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-with-directory-entries.zip' -Entries @( + 'docs/' + 'docs/thirdparty/' + $moduleEntries.renpy + ) + Assert-Success 'Expected directory records' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-valid.zip' -Entries $symbolsEntries + Assert-Success 'Combined symbols exact manifest' { + Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-missing.zip' -Entries @( + $moduleEntries.renpy | Where-Object { $_ -ne 'renpy.so' } + ) + Assert-Rejected 'Missing module entry' 'does not match' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-extra.zip' -Entries @( + $moduleEntries.renpy + 'unexpected.txt' + ) + Assert-Rejected 'Extra module entry' 'does not match' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-extra-directory.zip' -Entries @( + $moduleEntries.renpy + 'unexpected/' + ) + Assert-Rejected 'Extra directory entry' 'does not match' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-missing.zip' -Entries @( + $symbolsEntries | Where-Object { $_ -ne 'zanzarah.pdb' } + ) + Assert-Rejected 'Missing symbol entry' 'does not match' { + Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-extra.zip' -Entries @( + $symbolsEntries + 'unexpected.pdb' + ) + Assert-Rejected 'Extra symbol entry' 'does not match' { + Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-extra-directory.zip' -Entries @( + $symbolsEntries + 'symbols/' + ) + Assert-Rejected 'Extra symbols directory entry' 'does not match' { + Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-empty.zip' -Entries @() + Assert-Rejected 'Empty symbols archive' 'does not match' { + Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-duplicate.zip' -Entries @( + $moduleEntries.renpy + 'renpy.so' + ) + Assert-Rejected 'Duplicate ZIP entry' 'duplicate or case-colliding' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-case-collision.zip' -Entries @( + $moduleEntries.renpy + 'RenPy.so' + ) + Assert-Rejected 'Case-colliding ZIP entry' 'duplicate or case-colliding' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + + $unsafeNames = @( + '/absolute.txt' + 'C:/absolute.txt' + '../traversal.txt' + 'docs/../traversal.txt' + 'docs\backslash.txt' + 'docs//empty-segment.txt' + 'docs/./relative.txt' + 'docs/thirdparty/NUL.txt' + 'docs/thirdparty/bad:name.txt' + 'docs/thirdparty/trailing-dot.' + ) + foreach ($unsafeName in $unsafeNames) { + $archivePath = Write-TestZip -Directory $temporaryRoot -Name "unsafe-$([guid]::NewGuid().ToString('N')).zip" -Entries @( + $moduleEntries.renpy + $unsafeName + ) + Assert-Rejected "Unsafe ZIP entry '$unsafeName'" 'unsafe ZIP entry' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' + } + } + + $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'rpgmaker-wrong-thirdparty.zip' -Entries @( + $moduleEntries.rpgmaker | Where-Object { $_ -ne 'docs/thirdparty/rgssad.txt' } + 'docs/thirdparty/zanzapak.txt' + ) + Assert-Rejected 'Third-party document from another module' 'does not match' { + Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'rpgmaker' + } +} +finally { + $cleanupTarget = [System.IO.Path]::GetFullPath($temporaryRoot) + if (-not $cleanupTarget.Equals($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not $cleanupTarget.StartsWith($requiredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing unsafe package test cleanup target: $cleanupTarget" + } + if (Test-Path -LiteralPath $cleanupTarget) { + $cleanupItem = Get-Item -Force -LiteralPath $cleanupTarget + if (-not $cleanupItem.PSIsContainer -or + ($cleanupItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing unsafe package test cleanup directory: $cleanupTarget" + } + + $cleanupFiles = @(Get-ChildItem -Force -LiteralPath $cleanupTarget) + foreach ($cleanupFile in $cleanupFiles) { + $expectedParent = [System.IO.Path]::GetDirectoryName($cleanupFile.FullName) + $isUnsafeFile = $cleanupFile.PSIsContainer -or + ($cleanupFile.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not $cleanupFile.Extension.Equals('.zip', [System.StringComparison]::OrdinalIgnoreCase) -or + -not $expectedParent.Equals($cleanupTarget, [System.StringComparison]::OrdinalIgnoreCase) + if ($isUnsafeFile) { + throw "Refusing unexpected package test cleanup entry: $($cleanupFile.FullName)" + } + } + foreach ($cleanupFile in $cleanupFiles) { + Remove-Item -Force -LiteralPath $cleanupFile.FullName + } + Remove-Item -Force -LiteralPath $cleanupTarget + } +} + +Write-Host "[OK] Package manifest contract passed $assertionCount assertions." diff --git a/build/tests/package-smoke-contract.Tests.ps1 b/build/tests/package-smoke-contract.Tests.ps1 new file mode 100644 index 0000000..ddb797b --- /dev/null +++ b/build/tests/package-smoke-contract.Tests.ps1 @@ -0,0 +1,154 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$artifactsRoot = Join-Path $repositoryRoot '.artifacts' +$manifestModule = Join-Path $repositoryRoot 'build\lib\package-manifest.ps1' +$commonModule = Join-Path $repositoryRoot 'build\lib\common.ps1' +$smokeModule = Join-Path $repositoryRoot 'build\lib\package-smoke.ps1' +$buildEntrypoint = Join-Path $repositoryRoot 'build\build.ps1' + +if (-not (Test-Path -LiteralPath $smokeModule -PathType Leaf)) { + throw "Package smoke module is missing: $smokeModule" +} + +. $manifestModule +$script:RepositoryRoot = $repositoryRoot +. $commonModule +. $smokeModule + +function Assert-Throw { + param( + [Parameter(Mandatory)][scriptblock] $Action, + [Parameter(Mandatory)][string] $Description + ) + + try { + & $Action + } + catch { + return + } + throw "Expected failure: $Description" +} + +$temporaryRoot = [System.IO.Path]::GetFullPath( + (Join-Path $artifactsRoot "package-smoke-contract-$([guid]::NewGuid().ToString('N'))") +) +$artifactsPrefix = [System.IO.Path]::GetFullPath($artifactsRoot) + [System.IO.Path]::DirectorySeparatorChar +if (-not $temporaryRoot.StartsWith($artifactsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to create package smoke test data outside .artifacts: $temporaryRoot" +} + +try { + $extractedRoot = Join-Path $temporaryRoot 'renpy' + foreach ($relativeName in $script:ObserverModulePackageEntries.renpy) { + $path = Join-Path $extractedRoot ($relativeName.Replace('/', '\')) + $parent = Split-Path $path -Parent + New-Item -ItemType Directory -Force -Path $parent | Out-Null + [System.IO.File]::WriteAllText($path, $relativeName) + } + + [void](Assert-ObserverExtractedModulePackage -DirectoryPath $extractedRoot -ModuleName 'renpy') + + $extraPath = Join-Path $extractedRoot 'unexpected.txt' + [System.IO.File]::WriteAllText($extraPath, 'unexpected') + Assert-Throw -Description 'extra extracted package file' -Action { + Assert-ObserverExtractedModulePackage -DirectoryPath $extractedRoot -ModuleName 'renpy' + } + Remove-Item -LiteralPath $extraPath + + $symbolsRoot = Join-Path $temporaryRoot 'symbols' + New-Item -ItemType Directory -Force -Path $symbolsRoot | Out-Null + foreach ($relativeName in $script:ObserverSymbolsPackageEntries) { + [System.IO.File]::WriteAllText((Join-Path $symbolsRoot $relativeName), $relativeName) + } + [void](Assert-ObserverExtractedSymbolsPackage -DirectoryPath $symbolsRoot) + + $modulePath = Join-Path $extractedRoot 'renpy.so' + $probePath = Join-Path $temporaryRoot 'package-smoke-probe.cmd' + [System.IO.File]::WriteAllLines($probePath, @( + '@echo off', + ('if not "%OBSERVER_PACKAGE_MODULE%"=="{0}" exit /b 21' -f $modulePath), + 'if not "%OBSERVER_PACKAGE_FORMAT%"=="renpy" exit /b 22', + 'exit /b 0' + )) + + $previousModule = 'module-sentinel' + $previousFormat = 'format-sentinel' + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $previousModule, 'Process') + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $previousFormat, 'Process') + + Invoke-ObserverPackageRuntimeSmoke ` + -TestExecutablePath $probePath ` + -ModulePath $modulePath ` + -ModuleName 'renpy' ` + -WorkingDirectory $temporaryRoot + + if ([Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', 'Process') -ne $previousModule) { + throw 'Package smoke did not restore OBSERVER_PACKAGE_MODULE.' + } + if ([Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', 'Process') -ne $previousFormat) { + throw 'Package smoke did not restore OBSERVER_PACKAGE_FORMAT.' + } +} +finally { + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $null, 'Process') + [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $null, 'Process') + if (Test-Path -LiteralPath $temporaryRoot) { + $item = Get-Item -LiteralPath $temporaryRoot -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to remove reparse-point test directory: $temporaryRoot" + } + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} + +$orchestrationFiles = @( + Get-Item -LiteralPath $buildEntrypoint + Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name +) +$packageDefinitions = @( + foreach ($file in $orchestrationFiles) { + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, + [ref] $tokens, + [ref] $errors + ) + if ($errors.Count -ne 0) { + throw "PowerShell parse failed for '$($file.FullName)'." + } + foreach ($definition in $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-Package' + }, + $false + )) { + [pscustomobject]@{ File = $file.FullName; Definition = $definition } + } + } +) +if ($packageDefinitions.Count -ne 1) { + throw "Package orchestration must define Invoke-Package exactly once; found $($packageDefinitions.Count)." +} +$packageSource = $packageDefinitions[0].Definition.Extent.Text +foreach ($requiredCall in @( + 'Expand-ObserverModulePackageForSmoke', + 'Expand-ObserverSymbolsPackageForSmoke', + 'Invoke-ObserverPackageRuntimeSmoke' + )) { + if (-not $packageSource.Contains($requiredCall, [System.StringComparison]::Ordinal)) { + throw "Package orchestration does not invoke $requiredCall." + } +} +if ($packageSource.Contains('$PSScriptRoot', [System.StringComparison]::Ordinal)) { + throw 'Invoke-Package must use the caller-provided build context instead of its physical source location.' +} + +Write-Host 'Package smoke contracts passed.' diff --git a/build/tests/security-mitigation-contract.Tests.ps1 b/build/tests/security-mitigation-contract.Tests.ps1 new file mode 100644 index 0000000..0eaa4f1 --- /dev/null +++ b/build/tests/security-mitigation-contract.Tests.ps1 @@ -0,0 +1,36 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$projectPropertiesPath = Join-Path $repositoryRoot 'build\ObserverProject.props' +[xml]$projectProperties = Get-Content -LiteralPath $projectPropertiesPath -Raw +$namespace = [System.Xml.XmlNamespaceManager]::new($projectProperties.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + +$cetCompat = $projectProperties.SelectSingleNode( + '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:CETCompat', + $namespace +) +if ($null -eq $cetCompat -or $cetCompat.InnerText -ne 'true') { + throw 'Release CET compatibility must remain enabled for supported targets.' +} +$expectedCondition = "'`$(Configuration)' == 'Release' And '`$(Platform)' != 'ARM64'" +if ($cetCompat.Condition -ne $expectedCondition) { + throw "CET compatibility must exclude only ARM64; actual condition: '$($cetCompat.Condition)'." +} + +$linkControlFlowGuard = $projectProperties.SelectSingleNode( + '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:ControlFlowGuard', + $namespace +) +if ( + $null -eq $linkControlFlowGuard -or + $linkControlFlowGuard.InnerText -ne 'Guard' -or + $linkControlFlowGuard.Condition -ne "'`$(Configuration)' == 'Release'" +) { + throw 'Control Flow Guard must remain enabled for every Release architecture, including ARM64.' +} + +Write-Host '[OK] Release CET is scoped to supported targets without weakening CFG.' diff --git a/build/tests/test_dynamic_graph.py b/build/tests/test_dynamic_graph.py new file mode 100644 index 0000000..66da9f1 --- /dev/null +++ b/build/tests/test_dynamic_graph.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +from pathlib import PurePosixPath +import unittest + +from build.graph_driver import Graph +from build.native_graph import ( + FUZZ_PROJECT_NAMES, + build_project_node_name, + expand_x64_nodes, + fuzz_build_node_name, + load_manifest, +) +from build.dynamic_graph import ( + ARCHITECTURES, + FUZZ_TARGETS, + LEAK_MODES, + LEAK_SCENARIOS, + MODULES, + DynamicTopologyError, + build_dynamic_topology, + validate_dynamic_topology, +) + +WORKSPACE = Path(__file__).resolve().parents[2] + + +class DynamicGraphTopologyTests(unittest.TestCase): + def setUp(self) -> None: + self.topology = build_dynamic_topology( + architectures=ARCHITECTURES, + host_architecture="x64", + leak_windows=3, + run_id="contract-run", + ) + validate_dynamic_topology(self.topology) + self.nodes = {node["name"]: node for node in self.topology["nodes"]} + + def nodes_of_kind(self, kind: str) -> list[dict[str, object]]: + return [ + node + for node in self.topology["nodes"] + if f"kind={kind}" in node["fingerprint"] + ] + + def test_schema_is_normalized_and_stable(self) -> None: + self.assertEqual(2, self.topology["schema"]) + self.assertEqual( + sorted(self.nodes), + [node["name"] for node in self.topology["nodes"]], + ) + self.assertEqual( + json.dumps(self.topology, sort_keys=True, separators=(",", ":")), + json.dumps( + build_dynamic_topology( + architectures=ARCHITECTURES, + host_architecture="x64", + leak_windows=3, + run_id="contract-run", + ), + sort_keys=True, + separators=(",", ":"), + ), + ) + for node in self.topology["nodes"]: + self.assertEqual( + { + "name", + "deps", + "run_after", + "argv", + "resources", + "inputs", + "writes", + "outputs", + "fingerprint", + "cacheable", + }, + set(node), + ) + self.assertTrue(node["resources"], node["name"]) + self.assertIsInstance(node["resources"], dict) + self.assertEqual([], node["run_after"]) + for resource, units in node["resources"].items(): + self.assertTrue(resource) + self.assertGreater(units, 0) + + def test_every_write_and_output_has_one_owner(self) -> None: + write_owners: dict[str, str] = {} + output_owners: dict[str, str] = {} + for node in self.topology["nodes"]: + for field, owners in (("writes", write_owners), ("outputs", output_owners)): + for value in node[field]: + self.assertFalse(PurePosixPath(value).is_absolute(), value) + self.assertNotIn("..", PurePosixPath(value).parts, value) + self.assertNotIn(value, owners, f"{value}: {owners.get(value)} and {node['name']}") + owners[value] = node["name"] + + def test_validation_rejects_nested_write_ownership_and_external_collisions(self) -> None: + nested = deepcopy(self.topology) + first, second = nested["nodes"][:2] + second["writes"].append(first["writes"][0] + "/child") + with self.assertRaisesRegex(DynamicTopologyError, "overlap"): + validate_dynamic_topology(nested) + + collision = deepcopy(self.topology) + collision["external_nodes"].append(collision["nodes"][0]["name"]) + with self.assertRaisesRegex(DynamicTopologyError, "external"): + validate_dynamic_topology(collision) + + def test_fuzz_extends_four_native_build_nodes_with_replay_and_timed_pipelines(self) -> None: + native_names = { + node["name"] + for node in expand_x64_nodes( + load_manifest(WORKSPACE, project_names=FUZZ_PROJECT_NAMES) + ) + } + self.assertEqual(0, len(self.nodes_of_kind("fuzz-build"))) + self.assertEqual(4, len(self.nodes_of_kind("fuzz-seed-replay"))) + self.assertEqual(4, len(self.nodes_of_kind("fuzz-timed"))) + for target in FUZZ_TARGETS: + build_name = fuzz_build_node_name(target) + replay = self.nodes[f"fuzz-seed-replay-x64-{target}"] + timed = self.nodes[f"fuzz-timed-x64-{target}"] + self.assertIn(build_name, self.topology["external_nodes"]) + self.assertIn(build_name, native_names) + self.assertEqual([build_name], replay["deps"]) + self.assertEqual([replay["name"]], timed["deps"]) + self.assertFalse(replay["cacheable"]) + self.assertFalse(timed["cacheable"]) + self.assertTrue(all("contract-run" in path for path in replay["outputs"])) + self.assertTrue(all("contract-run" in path for path in timed["outputs"])) + self.assertIn("-RunId", replay["argv"]) + self.assertIn("contract-run", replay["argv"]) + self.assertIn("-TargetName", replay["argv"]) + self.assertIn(target, replay["argv"]) + self.assertIn("-Phase", timed["argv"]) + self.assertIn("timed", timed["argv"]) + self.assertEqual(1, timed["resources"][f"fuzz-writer-x64-{target}"]) + + def test_leaks_are_fourteen_sessions_with_parallel_offline_analysis(self) -> None: + self.assertEqual(14, len(self.nodes_of_kind("leak-preflight"))) + self.assertEqual(14, len(self.nodes_of_kind("leak-capture"))) + self.assertEqual(42, len(self.nodes_of_kind("leak-diff"))) + self.assertEqual(14, len(self.nodes_of_kind("leak-judge"))) + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"leak-{mode}-{scenario}" + preflight = self.nodes[f"{stem}-preflight"] + capture = self.nodes[f"{stem}-capture"] + judge = self.nodes[f"{stem}-judge"] + self.assertEqual(["leak-setup"], preflight["deps"]) + self.assertEqual([preflight["name"]], capture["deps"]) + self.assertIn("-Scenario", capture["argv"]) + self.assertIn(scenario, capture["argv"]) + self.assertEqual(1, capture["resources"]["umdh-capture"]) + expected_diffs = sorted( + [f"{stem}-diff-window-1", f"{stem}-diff-window-2", f"{stem}-diff-overall"] + ) + self.assertEqual(expected_diffs, judge["deps"]) + aggregate = self.nodes["leaks-aggregate"] + self.assertEqual(14, len(aggregate["deps"])) + + def test_audit_is_per_architecture_module_and_tool(self) -> None: + self.assertEqual(9, len(self.nodes_of_kind("audit-dumpbin"))) + self.assertEqual(9, len(self.nodes_of_kind("audit-binskim"))) + self.assertEqual(9, len(self.nodes_of_kind("audit-module"))) + for architecture in ARCHITECTURES: + for module in MODULES: + stem = f"audit-{architecture}-{module}" + release = ( + build_project_node_name("Release", module) + if architecture == "x64" + else f"release-{architecture}-{module}" + ) + self.assertEqual([release], self.nodes[f"{stem}-dumpbin"]["deps"]) + self.assertEqual([release], self.nodes[f"{stem}-binskim"]["deps"]) + self.assertEqual( + sorted([f"{stem}-dumpbin", f"{stem}-binskim"]), + self.nodes[stem]["deps"], + ) + + def test_package_has_creation_content_and_runtime_leaf_per_module_archive(self) -> None: + self.assertEqual(9, len(self.nodes_of_kind("package-create"))) + self.assertEqual(9, len(self.nodes_of_kind("package-content-smoke"))) + self.assertEqual(6, len(self.nodes_of_kind("package-runtime-smoke"))) + self.assertEqual(3, len(self.nodes_of_kind("package-runtime-deferred"))) + self.assertEqual(3, len(self.nodes_of_kind("symbols-create"))) + self.assertEqual(3, len(self.nodes_of_kind("symbols-content-smoke"))) + for architecture in ARCHITECTURES: + for module in MODULES: + stem = f"package-{architecture}-{module}" + create = self.nodes[f"{stem}-create"] + content = self.nodes[f"{stem}-content"] + runtime = self.nodes[f"{stem}-runtime"] + self.assertEqual( + sorted(["package-init", f"audit-{architecture}-{module}"]), + create["deps"], + ) + self.assertEqual([create["name"]], content["deps"]) + expected_runtime_kind = ( + "package-runtime-smoke" if architecture in {"x86", "x64"} else "package-runtime-deferred" + ) + self.assertIn(f"kind={expected_runtime_kind}", runtime["fingerprint"]) + expected_runtime_deps = [content["name"]] + if expected_runtime_kind == "package-runtime-smoke": + expected_runtime_deps.append( + build_project_node_name("Release", "tests") + if architecture == "x64" + else f"release-{architecture}-tests" + ) + self.assertEqual(sorted(expected_runtime_deps), runtime["deps"]) + evidence = self.nodes["package-evidence"] + self.assertEqual(12, len(evidence["deps"])) + + def test_x64_release_anchors_are_exported_by_the_native_generator(self) -> None: + native_names = { + node["name"] for node in expand_x64_nodes(load_manifest(WORKSPACE)) + } + expected = { + build_project_node_name("Release", project) + for project in (*MODULES, "tests", "leak-probe") + } + self.assertTrue(expected.issubset(native_names)) + self.assertTrue(expected.issubset(set(self.topology["external_nodes"]))) + self.assertIn( + build_project_node_name("Release", "leak-probe"), + self.nodes["leak-setup"]["deps"], + ) + + def test_x64_dynamic_records_compose_with_schema_v2_native_records(self) -> None: + dynamic = build_dynamic_topology( + architectures=("x64",), + host_architecture="x64", + leak_windows=3, + run_id="schema-v2-integration", + ) + native_nodes = expand_x64_nodes(load_manifest(WORKSPACE)) + resource_names = { + resource + for node in (*native_nodes, *dynamic["nodes"]) + for resource in node["resources"] + } + capacities = {resource: 64 for resource in resource_names} + source_checks = { + "name": "source-checks", + "deps": [], + "run_after": [], + "resources": {"cpu": 1}, + "argv": ["pwsh", "-NoProfile", "-Command", "exit 0"], + "inputs": [], + "writes": [], + "outputs": [], + "fingerprint": ["contract=test-source-checks"], + "cacheable": False, + } + Graph.from_mapping( + "native-dynamic-integration", + { + "resources": capacities, + "failure_policy": "continue", + "targets": ["leaks-aggregate", "package-evidence"], + "nodes": [source_checks, *native_nodes, *dynamic["nodes"]], + }, + WORKSPACE, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_graph_driver.py b/build/tests/test_graph_driver.py new file mode 100644 index 0000000..386048c --- /dev/null +++ b/build/tests/test_graph_driver.py @@ -0,0 +1,1023 @@ +from __future__ import annotations + +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import types +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = BUILD_ROOT.parent +DRIVER_PATH = BUILD_ROOT / "graph_driver.py" +ARTIFACTS_ROOT = REPOSITORY_ROOT / ".artifacts" +ARTIFACTS_ROOT.mkdir(exist_ok=True) + + +def load_driver() -> types.ModuleType: + spec = importlib.util.spec_from_file_location("observer_graph_driver", DRIVER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load graph driver from {DRIVER_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +driver = load_driver() + + +def node( + name: str, + *, + deps: tuple[str, ...] = (), + run_after: tuple[str, ...] = (), + resources: dict[str, int] | None = None, + argv: tuple[str, ...] = ("synthetic",), + inputs: tuple[str, ...] = (), + writes: tuple[str, ...] = (), + outputs: tuple[str, ...] = (), + fingerprint: tuple[str, ...] = (), + cacheable: bool = False, +) -> dict[str, object]: + return { + "name": name, + "deps": list(deps), + "run_after": list(run_after), + "resources": resources or {"cpu": 1}, + "argv": list(argv), + "inputs": list(inputs), + "writes": list(writes), + "outputs": list(outputs), + "fingerprint": list(fingerprint), + "cacheable": cacheable, + } + + +class GraphDriverTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory( + dir=ARTIFACTS_ROOT, + prefix="graph-driver-tests-", + ) + self.workspace = Path(self.temporary_directory.name) + self.logs = self.workspace / "logs" + self.state = self.workspace / "state" + + def tearDown(self) -> None: + for attempt in range(5): + try: + self.temporary_directory.cleanup() + return + except OSError: + if attempt == 4: + raise + time.sleep(0.02) + + def graph( + self, + nodes: list[dict[str, object]], + *, + resources: dict[str, int] | None = None, + targets: list[str] | None = None, + failure_policy: str = "continue", + ): + mapping = { + "failure_policy": failure_policy, + "resources": resources or {"cpu": 1}, + "targets": targets or [str(nodes[-1]["name"])], + "nodes": nodes, + } + return driver.Graph.from_mapping("synthetic", mapping, self.workspace) + + def write_profile(self, graph: dict[str, object]) -> Path: + path = self.workspace / "profile.json" + path.write_text( + json.dumps( + { + "schema": 2, + "default_graph": "synthetic", + "graphs": {"synthetic": graph}, + } + ), + encoding="utf-8", + ) + return path + + def test_cycle_detection_reports_a_deterministic_cycle(self) -> None: + graph = self.graph( + [ + node("c", deps=("a",)), + node("a", deps=("b",)), + node("b", deps=("c",)), + ], + targets=["a"], + ) + + with self.assertRaisesRegex(driver.CycleError, r"a -> b -> c -> a"): + graph.plan() + + def test_topological_plan_is_deterministic_across_declaration_order(self) -> None: + declarations = [ + node("d", deps=("a", "c")), + node("c", deps=("b",)), + node("b"), + node("a"), + ] + expected = ["a", "b", "c", "d"] + + first = self.graph(declarations, targets=["d"]) + second = self.graph(list(reversed(declarations)), targets=["d"]) + + self.assertEqual([item.node.name for item in first.plan()], expected) + self.assertEqual([item.node.name for item in second.plan()], expected) + + def test_unknown_graph_level_field_is_rejected(self) -> None: + mapping = { + "failure_policy": "continue", + "resources": {"cpu": 1}, + "targets": ["safe"], + "nodes": [node("safe")], + "resoruces": {"typo": 1}, + } + + with self.assertRaisesRegex(driver.GraphValidationError, "unknown graph fields"): + driver.Graph.from_mapping("synthetic", mapping, self.workspace) + + def test_workspace_run_lock_excludes_a_second_process(self) -> None: + probe = """ +import importlib.util +from pathlib import Path +import sys + +spec = importlib.util.spec_from_file_location("lock_probe_driver", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +try: + with module._WorkspaceRunLock(Path(sys.argv[2])): + pass +except module.GraphValidationError: + raise SystemExit(0) +raise SystemExit(1) +""" + + with driver._WorkspaceRunLock(self.workspace): + completed = subprocess.run( + [sys.executable, "-c", probe, str(DRIVER_PATH), str(self.workspace)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_graph_runner_holds_workspace_lock_for_the_entire_run(self) -> None: + graph = self.graph([node("gate")]) + runner = driver.GraphRunner(graph, self.logs, self.state, max_workers=1) + + with driver._WorkspaceRunLock(self.workspace): + with self.assertRaisesRegex(driver.GraphValidationError, "already running"): + runner.run() + + def test_continue_policy_blocks_dependents_but_runs_independent_nodes(self) -> None: + graph = self.graph( + [ + node("failure"), + node("dependent", deps=("failure",)), + node("transitive", deps=("dependent",)), + node("independent"), + ], + resources={"cpu": 4}, + targets=["transitive", "independent"], + ) + executed: list[str] = [] + lock = threading.Lock() + + def execute(current, _workspace, stream, _cancellation) -> int: + with lock: + executed.append(current.name) + stream.write(f"executed {current.name}\n") + return 19 if current.name == "failure" else 0 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=4, + executor=execute, + ).run() + + self.assertEqual(summary.results["failure"].status, "failed") + self.assertEqual(summary.results["dependent"].status, "blocked") + self.assertEqual(summary.results["transitive"].status, "blocked") + self.assertEqual(summary.results["independent"].status, "succeeded") + self.assertCountEqual(executed, ["failure", "independent"]) + + def test_order_only_finalizers_run_after_failure_while_dependents_block(self) -> None: + graph = self.graph( + [ + node("analyzer", writes=("raw",)), + node("normalizer", run_after=("analyzer",), writes=("sarif",)), + node("ordinary", deps=("analyzer",)), + node( + "final", + deps=("normalizer",), + run_after=("analyzer",), + inputs=("sarif",), + ), + ], + resources={"cpu": 2}, + targets=["ordinary", "final"], + ) + executed: list[str] = [] + + def execute(current, workspace, stream, _cancellation) -> int: + executed.append(current.name) + if current.name == "analyzer": + (workspace / "raw").write_text("diagnostic", encoding="utf-8") + return 9 + if current.name == "normalizer": + (workspace / "sarif").write_text("normalized diagnostic", encoding="utf-8") + return 0 + if current.name == "final": + stream.write((workspace / "sarif").read_text(encoding="utf-8")) + return 17 + return 0 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=2, + executor=execute, + ).run() + + self.assertEqual(executed, ["analyzer", "normalizer", "final"]) + self.assertEqual(summary.results["analyzer"].status, "failed") + self.assertEqual(summary.results["normalizer"].status, "succeeded") + self.assertEqual(summary.results["ordinary"].status, "blocked") + self.assertEqual(summary.results["final"].status, "failed") + self.assertIn("normalized diagnostic", summary.results["final"].log_path.read_text(encoding="utf-8")) + + def test_fail_fast_cancels_every_node_that_has_not_started(self) -> None: + graph = self.graph( + [ + node("a-failure"), + node("b-dependent", deps=("a-failure",)), + node("c-independent"), + ], + targets=["b-dependent", "c-independent"], + failure_policy="fail-fast", + ) + executed: list[str] = [] + + def execute(current, _workspace, stream, _cancellation) -> int: + executed.append(current.name) + stream.write(current.name) + return 7 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ).run() + + self.assertEqual(executed, ["a-failure"]) + self.assertEqual(summary.results["a-failure"].status, "failed") + self.assertEqual(summary.results["b-dependent"].status, "cancelled") + self.assertEqual(summary.results["c-independent"].status, "cancelled") + self.assertFalse(summary.succeeded) + + def test_fail_fast_signals_already_running_executors_through_the_cancellation_seam(self) -> None: + graph = self.graph( + [node("a-failure"), node("b-running")], + resources={"cpu": 2}, + targets=["a-failure", "b-running"], + failure_policy="fail-fast", + ) + both_started = threading.Barrier(2) + running_saw_cancellation = threading.Event() + + def execute(current, _workspace, stream, cancellation) -> int: + both_started.wait(timeout=2) + if current.name == "a-failure": + return 23 + if cancellation.wait(timeout=2): + running_saw_cancellation.set() + stream.write("running node stopped\n") + return 0 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=2, + executor=execute, + ).run() + + self.assertTrue(running_saw_cancellation.is_set()) + self.assertEqual(summary.results["a-failure"].status, "failed") + self.assertEqual(summary.results["b-running"].status, "cancelled") + + def test_weighted_resources_are_acquired_and_released_atomically(self) -> None: + declarations = [ + node("compile-a", resources={"cpu": 2, "toolchain": 1}), + node("compile-b", resources={"cpu": 2, "toolchain": 1}), + node("report", resources={"cpu": 1, "report-io": 1}), + ] + capacities = {"cpu": 3, "toolchain": 1, "report-io": 1} + graph = self.graph( + declarations, + resources=capacities, + targets=[str(item["name"]) for item in declarations], + ) + active = {name: 0 for name in capacities} + maximum = {name: 0 for name in capacities} + maximum_processes = 0 + process_count = 0 + lock = threading.Lock() + + def execute(current, _workspace, stream, _cancellation) -> int: + nonlocal maximum_processes, process_count + demands = dict(current.resources) + with lock: + process_count += 1 + maximum_processes = max(maximum_processes, process_count) + for resource, demand in demands.items(): + active[resource] += demand + maximum[resource] = max(maximum[resource], active[resource]) + time.sleep(0.04) + with lock: + for resource, demand in demands.items(): + active[resource] -= demand + process_count -= 1 + stream.write(f"executed {current.name}\n") + return 0 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=3, + executor=execute, + ).run() + + self.assertTrue(summary.succeeded) + self.assertLessEqual(maximum["cpu"], capacities["cpu"]) + self.assertLessEqual(maximum["toolchain"], capacities["toolchain"]) + self.assertGreaterEqual(maximum_processes, 2) + + def test_resource_demand_cannot_exceed_capacity(self) -> None: + with self.assertRaisesRegex(driver.GraphValidationError, "exceeds capacity"): + self.graph( + [node("too-heavy", resources={"cpu": 3})], + resources={"cpu": 2}, + ) + + def test_zero_max_workers_is_rejected(self) -> None: + graph = self.graph([node("safe")]) + + with self.assertRaisesRegex(driver.GraphValidationError, "max_workers must be positive"): + driver.GraphRunner(graph, self.logs, self.state, max_workers=0) + + def test_ready_queue_preserves_age_when_new_lexical_predecessors_arrive(self) -> None: + graph = self.graph( + [ + node("a0"), + node("a1", deps=("a0",)), + node("a2", deps=("a1",)), + node("z-old"), + ], + targets=["a2", "z-old"], + ) + executed: list[str] = [] + + def execute(current, _workspace, stream, _cancellation) -> int: + executed.append(current.name) + stream.write(current.name) + return 0 + + driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ).run() + + self.assertEqual(executed, ["a0", "z-old", "a1", "a2"]) + + def test_ready_queue_backfills_a_node_using_disjoint_resources(self) -> None: + graph = self.graph( + [ + node("a-hold", resources={"serial": 1}), + node("b-wait", resources={"serial": 1}), + node("c-other", resources={"other": 1}), + ], + resources={"serial": 1, "other": 1}, + targets=["a-hold", "b-wait", "c-other"], + ) + started: list[str] = [] + active = 0 + maximum = 0 + lock = threading.Lock() + + def execute(current, _workspace, stream, _cancellation) -> int: + nonlocal active, maximum + with lock: + started.append(current.name) + active += 1 + maximum = max(maximum, active) + time.sleep(0.04) + with lock: + active -= 1 + stream.write(current.name) + return 0 + + driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=2, + executor=execute, + ).run() + + self.assertEqual(started[0], "a-hold") + self.assertIn("c-other", started[:2]) + self.assertEqual(maximum, 2) + + def test_unordered_overlapping_write_roots_are_rejected(self) -> None: + graph = self.graph( + [ + node("first", writes=("out/shared",)), + node("second", writes=("out/shared/child",)), + ], + resources={"cpu": 2}, + targets=["first", "second"], + ) + + with self.assertRaisesRegex(driver.GraphValidationError, "overlapping writes"): + graph.plan() + + def test_dependency_order_allows_overlapping_write_roots(self) -> None: + graph = self.graph( + [ + node("first", writes=("out/shared",)), + node("second", deps=("first",), writes=("out/shared/child",)), + ], + targets=["second"], + ) + + self.assertEqual([item.node.name for item in graph.plan()], ["first", "second"]) + + def test_shared_capacity_one_resource_allows_overlapping_write_roots(self) -> None: + graph = self.graph( + [ + node("first", resources={"cpu": 1, "staging": 1}, writes=("out/shared",)), + node("second", resources={"cpu": 1, "staging": 1}, writes=("out/shared",)), + ], + resources={"cpu": 2, "staging": 1}, + targets=["first", "second"], + ) + + self.assertEqual(len(graph.plan()), 2) + + def test_shared_capacity_two_resource_does_not_protect_overlapping_writes(self) -> None: + graph = self.graph( + [ + node("first", resources={"staging": 1}, writes=("out/shared",)), + node("second", resources={"staging": 1}, writes=("out/shared",)), + ], + resources={"staging": 2}, + targets=["first", "second"], + ) + + with self.assertRaisesRegex(driver.GraphValidationError, "overlapping writes"): + graph.plan() + + def test_outputs_must_be_confined_below_a_declared_write_root(self) -> None: + with self.assertRaisesRegex(driver.GraphValidationError, "declared write root"): + self.graph( + [ + node( + "invalid", + writes=("out/owned",), + outputs=("out/elsewhere/result.txt",), + cacheable=True, + ) + ] + ) + + def test_graph_paths_and_runtime_directories_cannot_escape_workspace(self) -> None: + with self.assertRaisesRegex(driver.GraphValidationError, "unsafe input"): + self.graph([node("traversal", inputs=("../outside.txt",))]) + with self.assertRaisesRegex(driver.GraphValidationError, "unsafe write"): + self.graph([node("absolute", writes=("C:/outside",))]) + + graph = self.graph([node("safe")]) + with self.assertRaisesRegex(driver.GraphValidationError, "inside the workspace"): + driver.GraphRunner(graph, self.workspace.parent / "logs", self.state) + + def test_cacheable_node_requires_explicit_outputs(self) -> None: + with self.assertRaisesRegex(driver.GraphValidationError, "explicit outputs"): + self.graph([node("invalid", cacheable=True)]) + + def test_failed_rerun_invalidates_old_state_before_partial_output(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("output.txt",), + outputs=("output.txt",), + fingerprint=("tool=v1",), + cacheable=True, + ) + ] + ) + attempts = 0 + + def execute(_current, workspace, stream, _cancellation) -> int: + nonlocal attempts + attempts += 1 + (workspace / "output.txt").write_text(f"attempt={attempts}", encoding="utf-8") + stream.write(f"attempt={attempts}\n") + return 1 if attempts == 2 else 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + first = runner.run() + (self.workspace / "output.txt").unlink() + failed = runner.run() + recovered = runner.run() + + self.assertEqual(first.results["producer"].status, "succeeded") + self.assertEqual(failed.results["producer"].status, "failed") + self.assertEqual(recovered.results["producer"].status, "succeeded") + self.assertEqual(attempts, 3) + + def test_output_content_tampering_invalidates_cache(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("output.txt",), + outputs=("output.txt",), + cacheable=True, + ) + ] + ) + attempts = 0 + + def execute(_current, workspace, stream, _cancellation) -> int: + nonlocal attempts + attempts += 1 + (workspace / "output.txt").write_text(f"attempt={attempts}", encoding="utf-8") + stream.write(f"attempt={attempts}\n") + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + first = runner.run() + warm = runner.run() + (self.workspace / "output.txt").write_text("tampered", encoding="utf-8") + repaired = runner.run() + + self.assertEqual(first.results["producer"].status, "succeeded") + self.assertEqual(warm.results["producer"].status, "cached") + self.assertEqual(repaired.results["producer"].status, "succeeded") + self.assertEqual(attempts, 2) + state = json.loads((self.state / "producer.json").read_text(encoding="utf-8")) + self.assertEqual(state["schema"], 2) + self.assertEqual(state["outputs"][0]["path"], "output.txt") + self.assertIn("sha256", state["outputs"][0]) + + def test_successful_cacheable_node_hashes_its_output_manifest_once(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("output.txt",), + outputs=("output.txt",), + cacheable=True, + ) + ] + ) + + def execute(_current, workspace, stream, _cancellation) -> int: + (workspace / "output.txt").write_text("content", encoding="utf-8") + stream.write("produced") + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + with mock.patch.object(runner, "_output_manifest", wraps=runner._output_manifest) as manifest: + summary = runner.run() + + self.assertEqual(summary.results["producer"].status, "succeeded") + self.assertEqual(manifest.call_count, 1) + + def test_save_failure_becomes_failed_node_and_leaves_no_cache_state(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("output.txt",), + outputs=("output.txt",), + cacheable=True, + ) + ] + ) + + def execute(_current, workspace, _stream, _cancellation) -> int: + (workspace / "output.txt").write_text("content", encoding="utf-8") + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + with mock.patch.object(runner, "_save", side_effect=OSError("state disk full")): + summary = runner.run() + + result = summary.results["producer"] + self.assertEqual(result.status, "failed") + self.assertIn("state disk full", result.detail) + self.assertFalse((self.state / "producer.json").exists()) + + def test_missing_manifest_output_is_a_failed_node_with_invalidated_state(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("output.txt",), + outputs=("output.txt",), + cacheable=True, + ) + ] + ) + self.state.mkdir() + (self.state / "producer.json").write_text("stale", encoding="utf-8") + + def execute(_current, _workspace, _stream, _cancellation) -> int: + return 0 + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ).run() + + self.assertEqual(summary.results["producer"].status, "failed") + self.assertFalse((self.state / "producer.json").exists()) + + def test_ready_cache_validation_runs_outside_the_dispatch_loop(self) -> None: + declarations = [ + node( + name, + writes=(f"{name}.txt",), + outputs=(f"{name}.txt",), + cacheable=True, + ) + for name in ("first", "second") + ] + graph = self.graph( + declarations, + resources={"cpu": 2}, + targets=["first", "second"], + ) + barrier = threading.Barrier(2) + + def validate(_item) -> bool: + barrier.wait(timeout=2) + return False + + def execute(current, workspace, _stream, _cancellation) -> int: + (workspace / f"{current.name}.txt").write_text(current.name, encoding="utf-8") + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=2, + executor=execute, + ) + with mock.patch.object(runner, "_cached", side_effect=validate): + summary = runner.run() + + self.assertTrue(summary.succeeded) + + def test_cache_validation_obeys_capacity_one_output_lock(self) -> None: + declarations = [ + node( + name, + resources={"shared-output": 1}, + writes=("shared",), + outputs=("shared",), + cacheable=True, + ) + for name in ("first", "second") + ] + graph = self.graph( + declarations, + resources={"shared-output": 1}, + targets=["first", "second"], + ) + active = 0 + maximum = 0 + lock = threading.Lock() + + def validate(_item) -> bool: + nonlocal active, maximum + with lock: + active += 1 + maximum = max(maximum, active) + time.sleep(0.04) + with lock: + active -= 1 + return True + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=2, + executor=lambda *_args: 0, + ) + with mock.patch.object(runner, "_cached", side_effect=validate): + summary = runner.run() + + self.assertTrue(summary.succeeded) + self.assertEqual(maximum, 1) + + def test_rebuilt_prerequisite_prevents_a_stale_dependent_cache_hit(self) -> None: + graph = self.graph( + [ + node( + "producer", + writes=("producer.txt",), + outputs=("producer.txt",), + cacheable=True, + ), + node( + "consumer", + deps=("producer",), + inputs=("producer.txt",), + writes=("consumer.txt",), + outputs=("consumer.txt",), + cacheable=True, + ), + ], + targets=["consumer"], + ) + attempts = {"producer": 0, "consumer": 0} + + def execute(current, workspace, stream, _cancellation) -> int: + attempts[current.name] += 1 + if current.name == "producer": + (workspace / "producer.txt").write_text( + f"producer={attempts[current.name]}", + encoding="utf-8", + ) + else: + source = (workspace / "producer.txt").read_text(encoding="utf-8") + (workspace / "consumer.txt").write_text(source, encoding="utf-8") + stream.write(current.name) + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + runner.run() + warm = runner.run() + (self.workspace / "producer.txt").write_text("tampered", encoding="utf-8") + repaired = runner.run() + + self.assertEqual(warm.results["producer"].status, "cached") + self.assertEqual(warm.results["consumer"].status, "cached") + self.assertEqual(repaired.results["producer"].status, "succeeded") + self.assertEqual(repaired.results["consumer"].status, "succeeded") + self.assertEqual(attempts, {"producer": 2, "consumer": 2}) + + def test_every_run_uses_a_unique_log_directory(self) -> None: + graph = self.graph([node("gate")]) + attempts = 0 + + def execute(_current, _workspace, stream, _cancellation) -> int: + nonlocal attempts + attempts += 1 + stream.write(f"attempt={attempts}\n") + return 0 + + runner = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + executor=execute, + ) + first = runner.run().results["gate"] + second = runner.run().results["gate"] + + self.assertNotEqual(first.log_path, second.log_path) + self.assertIn("attempt=1", first.log_path.read_text(encoding="utf-8")) + self.assertIn("attempt=2", second.log_path.read_text(encoding="utf-8")) + + def test_shared_input_is_hashed_only_once_per_plan(self) -> None: + (self.workspace / "input.txt").write_text("content", encoding="utf-8") + graph = self.graph( + [ + node("first", inputs=("input.txt",)), + node("second", inputs=("input.txt",)), + ], + resources={"cpu": 2}, + targets=["first", "second"], + ) + + with mock.patch.object(driver, "_sha256_file", wraps=driver._sha256_file) as digest: + graph.plan() + + self.assertEqual(digest.call_count, 1) + + def test_matrix_expansion_is_deterministic_and_applies_exact_excludes(self) -> None: + build_node = node( + "build-{arch}", + writes=("out/build/{arch}",), + ) + build_node["matrix"] = { + "axes": {"arch": ["x86", "x64"]}, + "exclude": [], + } + matrix_node = node( + "test-{arch}-{config}", + deps=("build-{arch}",), + resources={"cpu": 1}, + argv=("tool", "--arch", "{arch}", "--config", "{config}"), + writes=("out/{arch}/{config}",), + ) + matrix_node["matrix"] = { + "axes": {"config": ["Release", "Debug"], "arch": ["x64", "x86"]}, + "exclude": [{"arch": "x86", "config": "Release"}], + } + profile = self.write_profile( + { + "variables": {}, + "failure_policy": "continue", + "resources": {"cpu": 2}, + "targets": ["test-x64-Debug", "test-x64-Release", "test-x86-Debug"], + "nodes": [matrix_node, build_node], + } + ) + + graph = driver.load_graph_profile(profile, None, self.workspace) + + self.assertEqual( + sorted(graph.nodes), + ["build-x64", "build-x86", "test-x64-Debug", "test-x64-Release", "test-x86-Debug"], + ) + self.assertEqual(graph.nodes["test-x64-Debug"].argv, ("tool", "--arch", "x64", "--config", "Debug")) + self.assertEqual(graph.nodes["test-x64-Debug"].deps, ("build-x64",)) + self.assertEqual(graph.nodes["test-x86-Debug"].writes, ("out/x86/Debug",)) + + def test_matrix_axis_cannot_collide_with_graph_variable(self) -> None: + matrix_node = node("test-{arch}") + matrix_node["matrix"] = {"axes": {"arch": ["x64"]}, "exclude": []} + profile = self.write_profile( + { + "variables": {"arch": "x64"}, + "resources": {"cpu": 1}, + "targets": ["test-x64"], + "nodes": [matrix_node], + } + ) + + with self.assertRaisesRegex(driver.GraphValidationError, "collides with graph variable"): + driver.load_graph_profile(profile, None, self.workspace) + + def test_matrix_exclude_must_be_an_exact_known_assignment(self) -> None: + matrix_node = node("test-{arch}-{config}") + matrix_node["matrix"] = { + "axes": {"arch": ["x64", "x86"], "config": ["Debug", "Release"]}, + "exclude": [{"arch": "x86"}], + } + profile = self.write_profile( + { + "variables": {}, + "resources": {"cpu": 1}, + "targets": ["test-x64-Debug"], + "nodes": [matrix_node], + } + ) + + with self.assertRaisesRegex(driver.GraphValidationError, "exact assignment"): + driver.load_graph_profile(profile, None, self.workspace) + + def test_profile_schema_one_is_rejected_without_ambiguous_compatibility(self) -> None: + profile = self.workspace / "v1.json" + profile.write_text(json.dumps({"schema": 1, "graphs": {}}), encoding="utf-8") + + with self.assertRaisesRegex(driver.GraphValidationError, "profile schema must be 2"): + driver.load_graph_profile(profile, None, self.workspace) + + def test_legacy_pool_field_is_rejected(self) -> None: + legacy = node("legacy") + legacy["pool"] = "cpu" + + with self.assertRaisesRegex(driver.GraphValidationError, "legacy pool"): + self.graph([legacy]) + + def test_default_executor_uses_argv_without_a_shell(self) -> None: + graph = self.graph([node("safe", argv=("program", "argument with spaces"))]) + current = graph.nodes["safe"] + completed = subprocess.CompletedProcess(current.argv, 0) + + with mock.patch.object(driver.subprocess, "run", return_value=completed) as run: + return_code = driver.execute_subprocess( + current, + self.workspace, + io.StringIO(), + threading.Event(), + ) + + self.assertEqual(return_code, 0) + positional, keyword = run.call_args + self.assertEqual(positional[0], ["program", "argument with spaces"]) + self.assertIs(keyword["shell"], False) + + def test_default_runner_writes_stdout_and_stderr_to_per_node_log(self) -> None: + graph = self.graph( + [ + node( + "logged", + argv=( + sys.executable, + "-c", + "import sys; print('stdout-line'); print('stderr-line', file=sys.stderr)", + ), + ) + ] + ) + + summary = driver.GraphRunner( + graph, + self.logs, + self.state, + max_workers=1, + ).run() + + result = summary.results["logged"] + self.assertEqual(result.status, "succeeded") + log_text = result.log_path.read_text(encoding="utf-8") + self.assertIn("stdout-line", log_text) + self.assertIn("stderr-line", log_text) + + def test_json_plan_output_is_stable_and_normalized(self) -> None: + graph = self.graph([node("b"), node("a")], resources={"cpu": 2}, targets=["a", "b"]) + + first = driver.plan_as_json(graph.plan()) + second = driver.plan_as_json(graph.plan()) + + self.assertEqual(first, second) + parsed = json.loads(first) + self.assertEqual([item["name"] for item in parsed], ["a", "b"]) + self.assertEqual(parsed[0]["resources"], {"cpu": 1}) + self.assertEqual(parsed[0]["writes"], []) + self.assertNotIn("pool", parsed[0]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_ixdag_execute.py b/build/tests/test_ixdag_execute.py new file mode 100644 index 0000000..519518d --- /dev/null +++ b/build/tests/test_ixdag_execute.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import asyncio +import hashlib +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +from build.ixdag.execute import ( + Command, + Executor, + Graph, + GraphError, + Node, + NodeExecutionError, + ProcessRequest, + run_process, +) + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +ARTIFACTS_ROOT = REPOSITORY_ROOT / ".artifacts" +ARTIFACTS_ROOT.mkdir(exist_ok=True) + + +def object_id(name: str) -> str: + return hashlib.sha256(name.encode("utf-8")).hexdigest() + + +def node( + name: str, + *, + deps: tuple[str, ...] = (), + pool: str = "cpu", + commands: tuple[Command, ...] | None = None, +) -> Node: + return Node( + name=name, + object_id=object_id(name), + deps=deps, + pool=pool, + commands=commands or (Command(("synthetic", name)),), + ) + + +class RecordingRunner: + def __init__(self, *, fail: str | None = None, delay: float = 0.0) -> None: + self.fail = fail + self.delay = delay + self.calls: list[ProcessRequest] = [] + self.active: dict[str, int] = {} + self.maximum: dict[str, int] = {} + + async def __call__(self, request: ProcessRequest) -> int: + self.calls.append(request) + pool = request.pool + self.active[pool] = self.active.get(pool, 0) + 1 + self.maximum[pool] = max(self.maximum.get(pool, 0), self.active[pool]) + try: + out_dir = Path(request.env["IX_OUT"]) + (out_dir / "payload.txt").write_text(request.node_name, encoding="utf-8") + if self.delay: + await asyncio.sleep(self.delay) + return 19 if request.node_name == self.fail else 0 + finally: + self.active[pool] -= 1 + + +class IxExecutorTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory( + dir=ARTIFACTS_ROOT, + prefix="ixdag-tests-", + ) + self.workspace = Path(self.temporary_directory.name) + self.state = self.workspace / "state" + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def graph( + self, + nodes: tuple[Node, ...], + *, + targets: tuple[str, ...], + pools: dict[str, int] | None = None, + ) -> Graph: + return Graph(nodes=nodes, targets=targets, pools=pools or {"cpu": 4}) + + async def test_demand_visit_deduplicates_a_shared_dependency(self) -> None: + graph = self.graph( + ( + node("shared"), + node("left", deps=("shared",)), + node("right", deps=("shared",)), + node("unreachable"), + ), + targets=("left", "right"), + ) + runner = RecordingRunner(delay=0.01) + + results = await Executor(graph, self.workspace, self.state, runner=runner).run() + + names = [request.node_name for request in runner.calls] + self.assertEqual(names.count("shared"), 1) + self.assertCountEqual(names, ["shared", "left", "right"]) + self.assertNotIn("unreachable", results) + + async def test_named_pool_semaphore_limits_concurrency(self) -> None: + graph = self.graph( + (node("first", pool="serial"), node("second", pool="serial")), + targets=("first", "second"), + pools={"serial": 1}, + ) + runner = RecordingRunner(delay=0.03) + + await Executor(graph, self.workspace, self.state, runner=runner).run() + + self.assertEqual(runner.maximum["serial"], 1) + self.assertEqual(runner.calls[0].env["IX_POOL_CAPACITY"], "1") + + async def test_complete_target_is_cached_without_visiting_its_dependencies(self) -> None: + graph = self.graph( + (node("dependency"), node("target", deps=("dependency",))), + targets=("target",), + ) + first_runner = RecordingRunner() + executor = Executor(graph, self.workspace, self.state, runner=first_runner) + await executor.run() + dependency_dir = executor.output_dir("dependency") + dependency_marker = dependency_dir / ".complete" + dependency_marker.unlink() + + warm_runner = RecordingRunner() + results = await Executor(graph, self.workspace, self.state, runner=warm_runner).run() + + self.assertEqual(results["target"].status, "cached") + self.assertEqual(warm_runner.calls, []) + self.assertFalse(dependency_marker.exists()) + + async def test_partial_output_is_moved_to_trash_before_rerun(self) -> None: + current = node("recover") + graph = self.graph((current,), targets=(current.name,)) + executor = Executor(graph, self.workspace, self.state, runner=RecordingRunner()) + out_dir = executor.output_dir(current.name) + out_dir.mkdir(parents=True) + (out_dir / "stale.txt").write_text("stale", encoding="utf-8") + + await executor.run() + + self.assertFalse((out_dir / "stale.txt").exists()) + trashed = list((self.state / "trash").glob("*")) + self.assertEqual(len(trashed), 1) + self.assertEqual((trashed[0] / "stale.txt").read_text(encoding="utf-8"), "stale") + self.assertTrue((out_dir / ".complete").is_file()) + + async def test_failed_node_is_trashed_and_does_not_publish_completion(self) -> None: + graph = self.graph( + (node("failure"), node("dependent", deps=("failure",))), + targets=("dependent",), + ) + runner = RecordingRunner(fail="failure") + executor = Executor(graph, self.workspace, self.state, runner=runner) + + with self.assertRaisesRegex(NodeExecutionError, "failure.*exit code 19"): + await executor.run() + + self.assertEqual([request.node_name for request in runner.calls], ["failure"]) + self.assertFalse(executor.output_dir("failure").exists()) + trashed_payloads = list((self.state / "trash").glob("*/payload.txt")) + self.assertEqual(len(trashed_payloads), 1) + self.assertEqual(trashed_payloads[0].read_text(encoding="utf-8"), "failure") + + async def test_failure_cancels_and_trashes_an_already_running_sibling(self) -> None: + graph = self.graph( + (node("failure"), node("slow")), + targets=("failure", "slow"), + ) + slow_started = asyncio.Event() + + async def runner(request: ProcessRequest) -> int: + out_dir = Path(request.env["IX_OUT"]) + (out_dir / "payload.txt").write_text(request.node_name, encoding="utf-8") + if request.node_name == "slow": + slow_started.set() + await asyncio.sleep(60) + return 0 + await slow_started.wait() + return 31 + + executor = Executor(graph, self.workspace, self.state, runner=runner) + + with self.assertRaisesRegex(NodeExecutionError, "failure.*exit code 31"): + await executor.run() + + self.assertFalse(executor.output_dir("failure").exists()) + self.assertFalse(executor.output_dir("slow").exists()) + trashed = list((self.state / "trash").glob("*/payload.txt")) + self.assertCountEqual( + [path.read_text(encoding="utf-8") for path in trashed], + ["failure", "slow"], + ) + + async def test_command_cwd_cannot_escape_the_workspace(self) -> None: + unsafe = node("unsafe", commands=(Command(("tool",), cwd=".."),)) + graph = self.graph((unsafe,), targets=(unsafe.name,)) + runner = RecordingRunner() + + with self.assertRaisesRegex(GraphError, "cwd must stay within workspace"): + await Executor(graph, self.workspace, self.state, runner=runner).run() + + self.assertEqual(runner.calls, []) + + async def test_default_process_runner_passes_literal_argv_without_a_shell(self) -> None: + process = mock.AsyncMock() + process.wait.return_value = 0 + request = ProcessRequest( + node_name="literal", + pool="cpu", + argv=("program", "argument with spaces", "¬-a-command"), + cwd=self.workspace, + env=dict(os.environ), + log_path=self.workspace / "literal.log", + ) + + with mock.patch.object( + asyncio, + "create_subprocess_exec", + return_value=process, + ) as create: + return_code = await run_process(request) + + self.assertEqual(return_code, 0) + positional, keyword = create.call_args + self.assertEqual(positional, request.argv) + self.assertNotIn("shell", keyword) + self.assertEqual(keyword["cwd"], str(self.workspace)) + + async def test_default_runner_merges_stdout_and_stderr_into_the_node_log(self) -> None: + logged = node( + "logged", + commands=( + Command( + ( + sys.executable, + "-c", + "import sys; print('stdout-line'); print('stderr-line', file=sys.stderr)", + ) + ), + ), + ) + graph = self.graph((logged,), targets=(logged.name,)) + + results = await Executor(graph, self.workspace, self.state).run() + + text = results["logged"].log_path.read_text(encoding="utf-8") + self.assertIn("stdout-line", text) + self.assertIn("stderr-line", text) + + async def test_cycle_is_rejected_instead_of_deadlocking_on_a_node_lock(self) -> None: + graph = self.graph( + (node("a", deps=("b",)), node("b", deps=("a",))), + targets=("a",), + ) + + with self.assertRaisesRegex(GraphError, "a -> b -> a"): + Executor(graph, self.workspace, self.state) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_ixdag_graph.py b/build/tests/test_ixdag_graph.py new file mode 100644 index 0000000..59bdc74 --- /dev/null +++ b/build/tests/test_ixdag_graph.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from build.ixdag.graph import ( + Graph, + GraphError, + Node, + ObserverMatrix, + Project, + TranslationUnit, + build_observer_microdag, + direct_renderer, +) + + +class IxDagNodeTests(unittest.TestCase): + def test_node_is_typed_immutable_and_has_one_named_pool(self) -> None: + node = Node( + name="compile-a", + deps=(), + pool="msvc", + argv=("cl", "/c", "src/a.cpp"), + inputs=("src/a.cpp",), + outputs=(".artifacts/a.obj",), + ) + + self.assertEqual("msvc", node.pool) + with self.assertRaises(FrozenInstanceError): + node.pool = "other" # type: ignore[misc] + with self.assertRaisesRegex(GraphError, "glob"): + Node("bad", (), "cpu", ("tool",), ("src/*.cpp",), ("out",)) + with self.assertRaisesRegex(GraphError, "relative"): + Node("bad", (), "cpu", ("tool",), ("../src/a.cpp",), ("out",)) + + def test_content_uid_changes_with_source_bytes_and_flows_to_consumers(self) -> None: + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + source = root / "source.txt" + source.write_text("first", encoding="utf-8") + graph = Graph( + nodes=( + Node("producer", (), "cpu", ("copy",), ("source.txt",), ("out/a",)), + Node("consumer", ("producer",), "cpu", ("use",), ("out/a",), ("out/b",)), + ), + targets=("consumer",), + ) + + first = graph.descriptors(root) + source.write_text("second", encoding="utf-8") + second = graph.descriptors(root) + + self.assertNotEqual(first["producer"]["uid"], second["producer"]["uid"]) + self.assertNotEqual(first["consumer"]["uid"], second["consumer"]["uid"]) + self.assertEqual("producer", first["consumer"]["deps"][0]) + + def test_graph_rejects_hidden_generated_inputs_and_duplicate_outputs(self) -> None: + with self.assertRaisesRegex(GraphError, "direct dependency"): + Graph( + nodes=( + Node("a", (), "cpu", ("a",), (), ("out/a",)), + Node("b", (), "cpu", ("b",), ("out/a",), ("out/b",)), + ), + targets=("b",), + ) + with self.assertRaisesRegex(GraphError, "output owner"): + Graph( + nodes=( + Node("a", (), "cpu", ("a",), (), ("out/shared",)), + Node("b", (), "cpu", ("b",), (), ("out/shared",)), + ), + targets=("b",), + ) + + +class IxDagObserverMatrixTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + paths = ( + "build/shared.props", + "build/projects/module.vcxproj", + "build/projects/tests.vcxproj", + "build/projects/fuzz-pickle.vcxproj", + "build/projects/leak-probe.vcxproj", + "src/module/a.cpp", + "src/module/b.cpp", + "src/tests/test.cpp", + "src/fuzz/pickle.cpp", + "src/leaks/probe.cpp", + "src/fuzz/corpus/pickle/seed", + ) + for relative_path in paths: + path = self.root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(relative_path, encoding="utf-8") + self.matrix = ObserverMatrix( + shared_inputs=("build/shared.props",), + projects=( + Project( + "module", + "build/projects/module.vcxproj", + ("Debug", "Release"), + ( + TranslationUnit("module", "src/module/a.cpp", "a"), + TranslationUnit("module", "src/module/b.cpp", "b"), + ), + ), + Project( + "tests", + "build/projects/tests.vcxproj", + ("Debug", "Release"), + (TranslationUnit("tests", "src/tests/test.cpp", "test"),), + ), + Project( + "fuzz-pickle", + "build/projects/fuzz-pickle.vcxproj", + ("Fuzz",), + (TranslationUnit("fuzz-pickle", "src/fuzz/pickle.cpp", "fuzz"),), + ), + Project( + "leak-probe", + "build/projects/leak-probe.vcxproj", + ("Release",), + (TranslationUnit("leak-probe", "src/leaks/probe.cpp", "probe"),), + ), + ), + test_configurations=("Debug", "Release"), + fuzz_targets=("pickle",), + fuzz_seed_inputs={"pickle": ("src/fuzz/corpus/pickle/seed",)}, + leak_modes=("operations", "lifecycle"), + leak_scenarios=("small-success", "malformed"), + ) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def test_matrix_expands_project_config_tu_and_scenario_axes(self) -> None: + graph = build_observer_microdag(self.matrix, renderer=direct_renderer) + nodes = {node.name: node for node in graph.nodes} + + builds = [name for name in nodes if name.startswith("build-")] + analyzes = [name for name in nodes if name.startswith("analyze-")] + normalizes = [name for name in nodes if name.startswith("normalize-")] + leak_cases = [name for name in nodes if name.startswith("leak-case-")] + self.assertEqual(6, len(builds)) + self.assertEqual(10, len(analyzes)) + self.assertEqual(10, len(normalizes)) + self.assertEqual(4, len(leak_cases)) + self.assertEqual( + tuple(sorted(name for name in normalizes if name.startswith("normalize-msvc-"))), + nodes["merge-msvc-sarif"].deps, + ) + self.assertEqual( + ("analysis-gate", "fuzz-gate", "leaks-gate", "run-tests-debug", "run-tests-release"), + nodes["verify"].deps, + ) + + def test_every_node_has_one_pool_and_exact_owned_inputs_outputs(self) -> None: + graph = build_observer_microdag(self.matrix, renderer=direct_renderer) + descriptors = graph.descriptors(self.root) + output_owners = { + output: node.name for node in graph.nodes for output in node.outputs + } + + self.assertEqual(set(descriptors), {node.name for node in graph.nodes}) + for node in graph.nodes: + self.assertTrue(node.pool) + self.assertIsInstance(node.pool, str) + self.assertTrue(node.outputs) + self.assertFalse(any("*" in path or "?" in path for path in node.inputs)) + for input_path in node.inputs: + owner = output_owners.get(input_path) + if owner is not None: + self.assertIn(owner, node.deps) + + def test_jinja_template_is_only_a_descriptor_emitter(self) -> None: + template = ( + Path(__file__).parents[1] / "ixdag" / "templates" / "node.json.j2" + ).read_text(encoding="utf-8") + + self.assertEqual("{{ descriptor | tojson }}", template.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_mutation_report.py b/build/tests/test_mutation_report.py new file mode 100644 index 0000000..86dfee6 --- /dev/null +++ b/build/tests/test_mutation_report.py @@ -0,0 +1,92 @@ +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = REPOSITORY_ROOT / "build" / "mutation" / "validate_report.py" + + +class MutationReportValidatorTests(unittest.TestCase): + def run_validator(self, report): + with tempfile.TemporaryDirectory() as directory: + report_path = Path(directory) / "mutation.json" + if isinstance(report, str): + report_path.write_text(report, encoding="utf-8") + else: + report_path.write_text(json.dumps(report), encoding="utf-8") + return subprocess.run( + [sys.executable, str(VALIDATOR), str(report_path)], + cwd=REPOSITORY_ROOT, + capture_output=True, + text=True, + check=False, + ) + + @staticmethod + def report(*mutants): + return { + "schemaVersion": "1.0", + "files": { + "src/modules/renpy/pickle.cpp": { + "language": "cpp", + "mutants": list(mutants), + } + }, + } + + def test_accepts_non_empty_report_when_every_mutant_is_killed(self): + result = self.run_validator( + self.report( + {"id": "1", "status": "Killed", "location": {"start": {"line": 10}}}, + {"id": "2", "status": "Killed", "location": {"start": {"line": 20}}}, + ) + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "mutation report: 2 reached mutants, all killed") + + def test_rejects_empty_report_instead_of_accepting_infinite_score(self): + result = self.run_validator(self.report()) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("no reached mutants", result.stderr) + + def test_rejects_surviving_mutant_with_actionable_identity(self): + result = self.run_validator( + self.report( + { + "id": "boundary-7", + "status": "Survived", + "location": {"start": {"line": 73}}, + } + ) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Survived", result.stderr) + self.assertIn("pickle.cpp:73", result.stderr) + self.assertIn("boundary-7", result.stderr) + + def test_rejects_non_killed_reached_statuses(self): + for status in ("NoCoverage", "Timeout", "RuntimeError", "Ignored"): + with self.subTest(status=status): + result = self.run_validator(self.report({"id": status, "status": status})) + self.assertNotEqual(result.returncode, 0) + self.assertIn(status, result.stderr) + + def test_rejects_malformed_json_and_malformed_mutants(self): + invalid_json = self.run_validator("not-json") + self.assertNotEqual(invalid_json.returncode, 0) + self.assertIn("valid JSON", invalid_json.stderr) + + invalid_mutant = self.run_validator(self.report({"id": "missing-status"})) + self.assertNotEqual(invalid_mutant.returncode, 0) + self.assertIn("status", invalid_mutant.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_native_graph.py b/build/tests/test_native_graph.py new file mode 100644 index 0000000..275f2de --- /dev/null +++ b/build/tests/test_native_graph.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from build import native_graph + + +WORKSPACE = Path(__file__).resolve().parents[2] + + +class NativeGraphManifestTests(unittest.TestCase): + def test_exact_project_and_translation_unit_manifest(self) -> None: + manifest = native_graph.load_manifest(WORKSPACE) + + self.assertEqual( + [project.name for project in manifest.projects], + [ + "renpy", + "rpgmaker", + "zanzarah", + "tests", + "fuzz-pickle", + "fuzz-renpy", + "fuzz-rpgmaker", + "fuzz-zanzarah", + "leak-probe", + ], + ) + self.assertEqual( + [len(project.translation_units) for project in manifest.projects], + [6, 4, 4, 15, 2, 5, 3, 3, 5], + ) + self.assertEqual(len(manifest.translation_units), 47) + self.assertEqual(len({unit.key for unit in manifest.translation_units}), 47) + + def test_manifest_rejects_a_project_outside_the_explicit_allowlist(self) -> None: + with self.assertRaisesRegex(native_graph.NativeGraphError, "unknown project"): + native_graph.load_manifest(WORKSPACE, project_names=("renpy", "not-a-project")) + + def test_manifest_paths_are_repository_relative_existing_cpp_files(self) -> None: + manifest = native_graph.load_manifest(WORKSPACE) + + for unit in manifest.translation_units: + self.assertFalse(unit.source.is_absolute()) + self.assertEqual(unit.source.suffix, ".cpp") + self.assertTrue((WORKSPACE / unit.source).is_file()) + self.assertNotIn("..", unit.source.parts) + + def test_item_definition_clcompile_is_not_a_translation_unit(self) -> None: + manifest = native_graph.load_manifest(WORKSPACE) + leak_probe = next(project for project in manifest.projects if project.name == "leak-probe") + + self.assertEqual( + [unit.source.as_posix() for unit in leak_probe.translation_units], + [ + "src/core/io/bounded_stream.cpp", + "src/tests/leaks/probe.cpp", + "src/core/compression/zlib_codec.cpp", + "src/tests/support/archive_fixtures.cpp", + "src/tests/support/zlib_fixture.cpp", + ], + ) + + +class NativeGraphExpansionTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.manifest = native_graph.load_manifest(WORKSPACE) + cls.nodes = native_graph.expand_x64_nodes(cls.manifest) + cls.by_name = {node["name"]: node for node in cls.nodes} + + def test_native_build_and_test_execution_are_separate_nodes(self) -> None: + build_nodes = [node for node in self.nodes if node["name"].startswith("build-")] + run_nodes = [node for node in self.nodes if node["name"].startswith("run-tests-")] + + self.assertEqual(len(build_nodes), 25) + self.assertEqual(len(run_nodes), 5) + for configuration in ("debug", "release", "coverage", "asan", "ubsan"): + runner = self.by_name[f"run-tests-{configuration}"] + self.assertEqual( + runner["deps"], + [ + f"build-{configuration}-renpy", + f"build-{configuration}-rpgmaker", + f"build-{configuration}-tests", + f"build-{configuration}-zanzarah", + ], + ) + self.assertEqual(runner["resources"], {"cpu": 1, "test-run": 1}) + + fuzz_builds = [node for node in build_nodes if node["name"].startswith("build-fuzz-")] + self.assertEqual(len(fuzz_builds), 4) + self.assertFalse(any(node["name"].startswith("run-fuzz-") for node in self.nodes)) + + def test_fuzz_build_names_have_one_exported_cross_graph_contract(self) -> None: + targets = ("pickle", "renpy", "rpgmaker", "zanzarah") + + self.assertEqual( + {native_graph.fuzz_build_node_name(target) for target in targets}, + { + node["name"] + for node in self.nodes + if node["name"].startswith("build-fuzz-") + }, + ) + with self.assertRaisesRegex(native_graph.NativeGraphError, "unknown fuzz target"): + native_graph.fuzz_build_node_name("unknown") + + def test_release_build_names_have_one_exported_cross_graph_contract(self) -> None: + expected = { + native_graph.build_project_node_name("Release", project) + for project in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") + } + + self.assertTrue(expected.issubset(self.by_name)) + with self.assertRaisesRegex(native_graph.NativeGraphError, "unsupported project build"): + native_graph.build_project_node_name("ASan", "leak-probe") + + def test_analysis_expands_to_47_msvc_and_47_tidy_units(self) -> None: + msvc_nodes = [node for node in self.nodes if node["name"].startswith("analyze-msvc-")] + tidy_nodes = [node for node in self.nodes if node["name"].startswith("analyze-tidy-")] + normalize_msvc = [node for node in self.nodes if node["name"].startswith("normalize-msvc-")] + normalize_tidy = [node for node in self.nodes if node["name"].startswith("normalize-tidy-")] + + self.assertEqual(len(msvc_nodes), 47) + self.assertEqual(len(tidy_nodes), 47) + self.assertEqual(len(normalize_msvc), 47) + self.assertEqual(len(normalize_tidy), 47) + self.assertEqual(len(self.by_name["merge-msvc-sarif"]["deps"]), 47) + self.assertEqual(len(self.by_name["merge-tidy-sarif"]["deps"]), 47) + self.assertEqual( + self.by_name["analysis-gate"]["deps"], + ["merge-msvc-sarif", "merge-tidy-sarif"], + ) + + def test_selected_file_tidy_uses_isolated_intdir_and_exact_source(self) -> None: + node = next( + node + for node in self.nodes + if node["name"].startswith("analyze-tidy-renpy-") + and any("src/core/io/bounded_stream.cpp" in argument for argument in node["argv"]) + ) + + self.assertIn("-SelectedFile", node["argv"]) + selected_index = node["argv"].index("-SelectedFile") + 1 + self.assertEqual(node["argv"][selected_index], "src/core/io/bounded_stream.cpp") + self.assertEqual(node["resources"], {"clang-tidy": 1, "cpu": 1}) + self.assertEqual(len(node["writes"]), 1) + self.assertTrue(node["writes"][0].startswith(".artifacts/analysis/clang-tidy/x64/renpy/")) + self.assertTrue(node["writes"][0].endswith("/")) + self.assertEqual( + node["outputs"], + [f"{node['writes'][0]}obj/renpy.ClangTidy.log"], + ) + + def test_selected_file_msvc_uses_isolated_intdir_and_exact_source(self) -> None: + node = next( + node + for node in self.nodes + if node["name"].startswith("analyze-msvc-renpy-") + and any("src/core/io/bounded_stream.cpp" in argument for argument in node["argv"]) + ) + + self.assertIn("-SelectedFile", node["argv"]) + selected_index = node["argv"].index("-SelectedFile") + 1 + self.assertEqual(node["argv"][selected_index], "src/core/io/bounded_stream.cpp") + self.assertEqual(node["resources"], {"cpu": 1, "memory-gib": 2, "msvc-analysis": 1}) + self.assertEqual(len(node["writes"]), 1) + self.assertTrue(node["writes"][0].startswith(".artifacts/analysis/msvc/x64/renpy/")) + self.assertTrue(node["writes"][0].endswith("/")) + + def test_sarif_merges_receive_exact_normalizer_outputs_without_globs(self) -> None: + for backend in ("msvc", "tidy"): + normalizers = [ + node + for node in self.nodes + if node["name"].startswith(f"normalize-{backend}-") + ] + expected = [node["outputs"][0] for node in normalizers] + merge = self.by_name[f"merge-{backend}-sarif"] + argument_index = merge["argv"].index("-InputPathsJson") + 1 + + self.assertEqual(merge["inputs"], expected) + self.assertEqual(json.loads(merge["argv"][argument_index]), expected) + self.assertFalse(any("*" in path or "?" in path for path in merge["inputs"])) + + def test_msvc_and_tidy_writes_are_disjoint_from_debug_build(self) -> None: + analysis_writes = { + write + for node in self.nodes + if node["name"].startswith(("analyze-msvc-", "analyze-tidy-")) + for write in node["writes"] + } + debug_writes = { + write + for node in self.nodes + if node["name"].startswith("build-debug-") + for write in node["writes"] + } + + self.assertTrue(analysis_writes) + self.assertTrue(debug_writes) + self.assertTrue(analysis_writes.isdisjoint(debug_writes)) + for node in self.nodes: + for write in node["writes"]: + self.assertTrue(write.startswith(".artifacts/"), (node["name"], write)) + + def test_normalized_records_use_weighted_resources_and_explicit_writes(self) -> None: + self.assertEqual(len(self.nodes), len(self.by_name)) + for node in self.nodes: + self.assertIsInstance(node["resources"], dict) + self.assertTrue(node["resources"]) + self.assertTrue(all(weight > 0 for weight in node["resources"].values())) + self.assertEqual(node["run_after"], []) + self.assertIsInstance(node["writes"], list) + self.assertIn("outputs", node) + self.assertIn("cacheable", node) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_verify_graph.py b/build/tests/test_verify_graph.py new file mode 100644 index 0000000..fc562fc --- /dev/null +++ b/build/tests/test_verify_graph.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from build import graph_driver, verify_graph + + +WORKSPACE = Path(__file__).resolve().parents[2] + + +class VerifyGraphComposerTests(unittest.TestCase): + def test_analysis_scope_is_schema_v2_and_runnable(self) -> None: + composition = verify_graph.compose_x64_graph(WORKSPACE, scope="analysis") + + self.assertTrue(composition.runnable) + self.assertEqual(composition.mapping["targets"], ["analysis-gate"]) + self.assertEqual(composition.mapping["failure_policy"], "continue") + graph = graph_driver.Graph.from_mapping( + composition.name, composition.mapping, WORKSPACE + ) + names = set(graph._order(graph.targets)) + self.assertIn("doctor", names) + self.assertIn("restore", names) + self.assertIn("source-checks", names) + self.assertIn("analysis-gate", names) + self.assertEqual(len([name for name in names if name.startswith("analyze-msvc-")]), 47) + self.assertEqual(len([name for name in names if name.startswith("analyze-tidy-")]), 47) + + def test_native_graph_argv_binds_to_public_graph_leaf_entrypoint(self) -> None: + composition = verify_graph.compose_x64_graph(WORKSPACE, scope="analysis") + nodes = {node["name"]: node for node in composition.mapping["nodes"]} + + for name, node in nodes.items(): + if name in {"doctor", "restore", "source-checks"}: + continue + argv = node["argv"] + self.assertEqual(argv[:5], ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1"]) + self.assertEqual(argv[5], "graph-leaf") + self.assertEqual(argv[6], "-GraphLeafAction") + + def test_dynamic_external_dependencies_are_exactly_satisfied_by_native_nodes(self) -> None: + composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") + + self.assertFalse(composition.runnable) + self.assertEqual(composition.unwired_sections, ("dynamic",)) + self.assertEqual( + composition.external_dependencies, + ( + "build-fuzz-pickle", + "build-fuzz-renpy", + "build-fuzz-rpgmaker", + "build-fuzz-zanzarah", + "build-release-leak-probe", + "build-release-renpy", + "build-release-rpgmaker", + "build-release-tests", + "build-release-zanzarah", + ), + ) + names = {node["name"] for node in composition.mapping["nodes"]} + self.assertTrue(set(composition.external_dependencies).issubset(names)) + + def test_full_scope_is_plan_only_and_covers_dynamic_terminal_nodes(self) -> None: + composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") + + self.assertFalse(composition.runnable) + self.assertEqual( + composition.mapping["targets"], + [ + "analysis-gate", + "fuzz-timed-x64-pickle", + "fuzz-timed-x64-renpy", + "fuzz-timed-x64-rpgmaker", + "fuzz-timed-x64-zanzarah", + "leaks-aggregate", + "package-evidence", + "run-tests-asan", + "run-tests-coverage", + "run-tests-debug", + "run-tests-release", + "run-tests-ubsan", + ], + ) + graph = graph_driver.Graph.from_mapping(composition.name, composition.mapping, WORKSPACE) + graph._validate_write_conflicts(graph._order(graph.targets)) + + def test_every_resource_claim_has_declared_capacity(self) -> None: + composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") + capacities = composition.mapping["resources"] + + self.assertEqual( + capacities, + { + "archive-io": 2, + "binskim": 1, + "clang-tidy": 4, + "cpu": 12, + "dumpbin": 2, + "fuzz-runtime": 4, + "fuzz-writer-x64-pickle": 1, + "fuzz-writer-x64-renpy": 1, + "fuzz-writer-x64-rpgmaker": 1, + "fuzz-writer-x64-zanzarah": 1, + "memory-gib": 16, + "msvc-analysis": 4, + "native-msbuild": 2, + "package-init": 1, + "runtime-smoke": 2, + "sarif": 4, + "test-run": 2, + "umdh-capture": 1, + "umdh-diff": 2, + "umdh-session": 1, + }, + ) + for node in composition.mapping["nodes"]: + for resource, demand in node["resources"].items(): + self.assertLessEqual(demand, capacities[resource], (node["name"], resource)) + + def test_unknown_scope_is_rejected(self) -> None: + with self.assertRaisesRegex(verify_graph.VerifyGraphError, "unknown scope"): + verify_graph.compose_x64_graph(WORKSPACE, scope="not-a-scope") + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/verify-orchestration-contract.Tests.ps1 b/build/tests/verify-orchestration-contract.Tests.ps1 new file mode 100644 index 0000000..1aa35af --- /dev/null +++ b/build/tests/verify-orchestration-contract.Tests.ps1 @@ -0,0 +1,98 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Read-PowerShellAst { + param([Parameter(Mandatory)][string] $Path) + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) + if ($errors.Count -ne 0) { + $messages = @($errors | ForEach-Object Message) + throw "PowerShell parse failed for '$Path': $($messages -join '; ')" + } + return $ast +} + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$entrypointPath = Join-Path $repositoryRoot 'build\build.ps1' +$orchestrationFiles = @( + Get-Item -LiteralPath $entrypointPath + Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name +) +$verifyFunctions = @( + foreach ($file in $orchestrationFiles) { + $ast = Read-PowerShellAst -Path $file.FullName + foreach ($definition in $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-Verify' + }, + $false + )) { + [pscustomobject]@{ File = $file.FullName; Definition = $definition } + } + } +) +if ($verifyFunctions.Count -ne 1) { + throw 'The build orchestration sources must define exactly one Invoke-Verify orchestrator.' +} + +$verifyText = $verifyFunctions[0].Definition.Extent.Text +foreach ($requiredFragment in @( + 'Get-CurrentVerifyHostArchitecture', + 'Get-VerifyRoutingPlan', + '$plan.RequestedArchitectures', + '$plan.Builds', + '$plan.TestRuns', + '$plan.SpecialistGates', + '$plan.PackageContentArchitectures', + '$plan.Deferred', + 'Invoke-Restore', + 'Invoke-Lint', + 'Invoke-Build', + 'Invoke-TestExecutable', + 'Invoke-CodeAnalysis', + 'Invoke-Coverage', + 'Invoke-ASan', + 'Invoke-Ubsan', + 'Invoke-LeakTest', + 'Invoke-Fuzz', + "-TargetName 'all'", + 'Invoke-Package' + )) { + if (-not $verifyText.Contains($requiredFragment, [StringComparison]::Ordinal)) { + throw "Invoke-Verify is missing the required plan-driven step: $requiredFragment" + } +} + +if ($verifyText -match "@\(\s*'(x86|x64|arm64)'" -or + $verifyText -match "@\(\s*'(Debug|Release)'") { + throw 'Invoke-Verify must consume the routing plan instead of declaring an architecture/configuration matrix.' +} +if ($verifyText.Contains('$PSScriptRoot', [StringComparison]::Ordinal)) { + throw 'Invoke-Verify must use the caller-provided build context instead of its physical source location.' +} + +$entrypointText = Get-Content -Raw -LiteralPath $entrypointPath +$verifyDispatch = [regex]::Match( + $entrypointText, + "(?s)'verify'\s*\{(?.*?)\r?\n\s*\}\r?\n\s*'clean'" +) +if (-not $verifyDispatch.Success) { + throw 'The verify command dispatch block is missing.' +} +$dispatchBody = $verifyDispatch.Groups['body'].Value +if (-not $dispatchBody.Contains('Invoke-Verify', [StringComparison]::Ordinal)) { + throw 'The verify command must delegate to Invoke-Verify.' +} +foreach ($forbiddenCall in @('Invoke-Lint', 'Invoke-Test ', 'Invoke-Audit')) { + if ($dispatchBody.Contains($forbiddenCall, [StringComparison]::Ordinal)) { + throw "The verify command duplicates orchestration outside the routing-plan consumer: $forbiddenCall" + } +} + +Write-Host '[OK] Verify orchestration is complete and consumes the routing plan without a duplicate matrix.' diff --git a/build/tests/verify-routing.Tests.ps1 b/build/tests/verify-routing.Tests.ps1 new file mode 100644 index 0000000..47a94ae --- /dev/null +++ b/build/tests/verify-routing.Tests.ps1 @@ -0,0 +1,65 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$routingModule = Join-Path $repositoryRoot 'build\lib\verify-routing.ps1' +if (-not (Test-Path -LiteralPath $routingModule -PathType Leaf)) { + throw 'Verify routing planner is missing.' +} +. $routingModule + +function Assert-SetEqual { + param( + [Parameter(Mandatory)][string[]] $Actual, + [Parameter(Mandatory)][string[]] $Expected, + [Parameter(Mandatory)][string] $Description + ) + + if (@(Compare-Object -ReferenceObject $Expected -DifferenceObject $Actual).Count -ne 0) { + throw "$Description differs from the expected routing contract." + } +} + +$x64Plan = Get-VerifyRoutingPlan -HostArchitecture x64 -RequestedArchitectures @('x86', 'x64', 'arm64') +Assert-SetEqual -Description 'Requested architectures' -Actual @($x64Plan.RequestedArchitectures) -Expected @( + 'x86', 'x64', 'arm64' +) +Assert-SetEqual -Description 'Build matrix' -Actual @($x64Plan.Builds | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( + 'x86:Debug', 'x86:Release', 'x64:Debug', 'x64:Release', 'arm64:Debug', 'arm64:Release' +) +Assert-SetEqual -Description 'Runnable deterministic tests' -Actual @($x64Plan.TestRuns | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( + 'x86:Debug', 'x86:Release', 'x64:Debug', 'x64:Release' +) +Assert-SetEqual -Description 'Specialist gates' -Actual @($x64Plan.SpecialistGates | ForEach-Object { "$($_.Name):$($_.Architecture)" }) -Expected @( + 'coverage:x64', 'asan:x86', 'asan:x64', 'ubsan:x64', 'leaks:x64', 'formats:x64' +) +Assert-SetEqual -Description 'Package content checks' -Actual @($x64Plan.PackageContentArchitectures) -Expected @( + 'x86', 'x64', 'arm64' +) +Assert-SetEqual -Description 'Package runtime checks' -Actual @($x64Plan.PackageRuntimeArchitectures) -Expected @( + 'x86', 'x64' +) + +$deferredKeys = @($x64Plan.Deferred | ForEach-Object { "$($_.Gate):$($_.Architecture)" }) +Assert-SetEqual -Description 'Deferred native work' -Actual $deferredKeys -Expected @('tests:arm64', 'package-runtime:arm64') +if (@($x64Plan.Deferred | Where-Object { [string]::IsNullOrWhiteSpace($_.Reason) }).Count -ne 0) { + throw 'Every deferred verify action must include a reason.' +} + +$currentHost = Get-CurrentVerifyHostArchitecture +if ($currentHost -notin @('x86', 'x64', 'arm64')) { + throw "The current verify host architecture is unsupported: $currentHost" +} + +$arm64Plan = Get-VerifyRoutingPlan -HostArchitecture arm64 -RequestedArchitectures @('arm64') +Assert-SetEqual -Description 'Native ARM64 tests' -Actual @($arm64Plan.TestRuns | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( + 'arm64:Debug', 'arm64:Release' +) +Assert-SetEqual -Description 'Native ARM64 package smoke' -Actual @($arm64Plan.PackageRuntimeArchitectures) -Expected @('arm64') +if ($arm64Plan.Deferred.Count -ne 0) { + throw 'A native ARM64 host must not defer requested ARM64 runtime checks.' +} + +Write-Host '[OK] Verify routing preserves build coverage and reports non-native runtime work explicitly.' diff --git a/build/vcpkg/triplets/observer-arm64-windows-static.cmake b/build/vcpkg/triplets/observer-arm64-windows-static.cmake new file mode 100644 index 0000000..9ea8b57 --- /dev/null +++ b/build/vcpkg/triplets/observer-arm64-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE arm64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake new file mode 100644 index 0000000..c347497 --- /dev/null +++ b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake @@ -0,0 +1,6 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_BUILD_TYPE release) +set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address") diff --git a/build/vcpkg/triplets/observer-x64-windows-static.cmake b/build/vcpkg/triplets/observer-x64-windows-static.cmake new file mode 100644 index 0000000..8e9ad7f --- /dev/null +++ b/build/vcpkg/triplets/observer-x64-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake new file mode 100644 index 0000000..5909984 --- /dev/null +++ b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake @@ -0,0 +1,6 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_BUILD_TYPE release) +set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address") diff --git a/build/vcpkg/triplets/observer-x86-windows-static.cmake b/build/vcpkg/triplets/observer-x86-windows-static.cmake new file mode 100644 index 0000000..f4743bb --- /dev/null +++ b/build/vcpkg/triplets/observer-x86-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/build/verify_graph.py b/build/verify_graph.py new file mode 100644 index 0000000..fc89f74 --- /dev/null +++ b/build/verify_graph.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Compose and optionally execute the typed x64 verification micro-DAG.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +from pathlib import Path +import sys +from typing import Mapping + +from build import dynamic_graph, graph_driver, native_graph + + +ANALYSIS_SCOPE = "analysis" +FULL_SCOPE = "full" +SCOPES = (ANALYSIS_SCOPE, FULL_SCOPE) + +RESOURCE_CAPACITIES = { + "archive-io": 2, + "binskim": 1, + "clang-tidy": 4, + "cpu": 12, + "dumpbin": 2, + "fuzz-runtime": 4, + "fuzz-writer-x64-pickle": 1, + "fuzz-writer-x64-renpy": 1, + "fuzz-writer-x64-rpgmaker": 1, + "fuzz-writer-x64-zanzarah": 1, + "memory-gib": 16, + "msvc-analysis": 4, + "native-msbuild": 2, + "package-init": 1, + "runtime-smoke": 2, + "sarif": 4, + "test-run": 2, + "umdh-capture": 1, + "umdh-diff": 2, + "umdh-session": 1, +} + +FULL_TARGETS = ( + "analysis-gate", + "fuzz-timed-x64-pickle", + "fuzz-timed-x64-renpy", + "fuzz-timed-x64-rpgmaker", + "fuzz-timed-x64-zanzarah", + "leaks-aggregate", + "package-evidence", + "run-tests-asan", + "run-tests-coverage", + "run-tests-debug", + "run-tests-release", + "run-tests-ubsan", +) + + +class VerifyGraphError(ValueError): + """Raised when typed graph sections cannot be composed without ambiguity.""" + + +@dataclass(frozen=True) +class GraphComposition: + name: str + scope: str + mapping: Mapping[str, object] + runnable: bool + unwired_sections: tuple[str, ...] + external_dependencies: tuple[str, ...] + + +def _leaf_argv(command: str, *arguments: str) -> list[str]: + return [ + "pwsh", + "-NoLogo", + "-NoProfile", + "-File", + "build.ps1", + command, + *arguments, + ] + + +def _root_nodes() -> list[dict[str, object]]: + common_inputs = [ + "build.ps1", + "build/**/*.ps1", + "build/**/*.props", + "build/**/*.proj", + "build/**/*.vcxproj", + "vcpkg.json", + ] + return [ + { + "name": "doctor", + "deps": [], + "run_after": [], + "resources": {"cpu": 1}, + "argv": _leaf_argv("doctor"), + "inputs": common_inputs, + "writes": [], + "outputs": [], + "fingerprint": ["contract=verify-root-v1", "node=doctor"], + "cacheable": False, + }, + { + "name": "restore", + "deps": ["doctor"], + "run_after": [], + "resources": {"cpu": 2, "memory-gib": 2}, + "argv": _leaf_argv("restore", "-Arch", "x64", "-RestoreFlavor", "all"), + "inputs": [ + *common_inputs, + "vcpkg-configuration.json", + "build/vcpkg/triplets/*.cmake", + ], + "writes": [".artifacts/vcpkg_installed"], + "outputs": [], + "fingerprint": ["contract=verify-root-v1", "node=restore", "flavors=default+asan"], + "cacheable": False, + }, + { + "name": "source-checks", + "deps": ["restore"], + "run_after": [], + "resources": {"cpu": 4, "memory-gib": 4}, + "argv": _leaf_argv( + "source-checks", "-Arch", "x64", "-SkipDependencyRestore" + ), + "inputs": [".clang-format", ".clang-tidy", *common_inputs, "src/**/*.cpp", "src/**/*.h"], + "writes": [ + ".artifacts/reports/cppcheck", + ".artifacts/reports/psscriptanalyzer", + ], + "outputs": [], + "fingerprint": ["contract=verify-root-v1", "node=source-checks"], + "cacheable": False, + }, + ] + + +def _validate_unique_nodes(nodes: list[dict[str, object]]) -> set[str]: + names = [node.get("name") for node in nodes] + if any(not isinstance(name, str) or not name for name in names): + raise VerifyGraphError("every composed node requires a non-empty string name") + unique = set(names) + if len(unique) != len(names): + duplicates = sorted(name for name in unique if names.count(name) > 1) + raise VerifyGraphError(f"composed graph contains duplicate nodes: {duplicates}") + return unique + + +def _validate_resources(nodes: list[dict[str, object]]) -> None: + for node in nodes: + claims = node.get("resources") + if not isinstance(claims, dict) or not claims: + raise VerifyGraphError(f"node has no resource claims: {node.get('name')}") + for resource, demand in claims.items(): + capacity = RESOURCE_CAPACITIES.get(resource) + if capacity is None: + raise VerifyGraphError( + f"undeclared resource {resource!r} in {node.get('name')}" + ) + if isinstance(demand, bool) or not isinstance(demand, int) or not 1 <= demand <= capacity: + raise VerifyGraphError( + f"invalid demand for {node.get('name')}/{resource}: {demand!r}" + ) + + +def compose_x64_graph(workspace: Path, *, scope: str, run_id: str = "local") -> GraphComposition: + """Compose normalized schema-v2 records and expose only honestly runnable scopes.""" + + if scope not in SCOPES: + raise VerifyGraphError(f"unknown scope: {scope!r}") + root = Path(workspace).resolve() + manifest = native_graph.load_manifest(root) + native_nodes = native_graph.expand_x64_nodes(manifest) + nodes = [*_root_nodes(), *native_nodes] + external_dependencies: tuple[str, ...] = () + + if scope == FULL_SCOPE: + dynamic = dynamic_graph.build_dynamic_topology( + architectures=("x64",), + host_architecture="x64", + leak_windows=3, + run_id=run_id, + ) + external_dependencies = tuple(dynamic["external_nodes"]) + native_names = _validate_unique_nodes(nodes) + missing = sorted(set(external_dependencies) - native_names) + if missing: + raise VerifyGraphError( + f"dynamic graph has unsatisfied external dependencies: {missing}" + ) + nodes.extend(dynamic["nodes"]) + + names = _validate_unique_nodes(nodes) + targets = ["analysis-gate"] if scope == ANALYSIS_SCOPE else list(FULL_TARGETS) + unknown_targets = sorted(set(targets) - names) + if unknown_targets: + raise VerifyGraphError(f"scope has unknown targets: {unknown_targets}") + _validate_resources(nodes) + + mapping: dict[str, object] = { + "resources": dict(RESOURCE_CAPACITIES), + "failure_policy": "continue", + "targets": targets, + "nodes": nodes, + } + # Let the production runner validate dependencies, paths, cycles, and write ownership. + graph = graph_driver.Graph.from_mapping(f"observer-x64-{scope}", mapping, root) + order = graph._order(graph.targets) + graph._validate_write_conflicts(order) + runnable = scope == ANALYSIS_SCOPE + unwired = () if runnable else ("dynamic",) + return GraphComposition( + name=f"observer-x64-{scope}", + scope=scope, + mapping=mapping, + runnable=runnable, + unwired_sections=unwired, + external_dependencies=external_dependencies, + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + for command in ("plan", "run"): + child = subcommands.add_parser(command) + child.add_argument("--scope", choices=SCOPES, default=ANALYSIS_SCOPE) + child.add_argument( + "--workspace", type=Path, default=Path(__file__).resolve().parent.parent + ) + child.add_argument("--run-id", default="local") + subcommands.choices["plan"].add_argument("--json", action="store_true") + subcommands.choices["run"].add_argument("--jobs", type=int) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + composition = compose_x64_graph( + args.workspace, scope=args.scope, run_id=args.run_id + ) + graph = graph_driver.Graph.from_mapping( + composition.name, composition.mapping, args.workspace + ) + if args.command == "plan": + plan = graph.plan() + if args.json: + print(graph_driver.plan_as_json(plan), end="") + else: + status = "runnable" if composition.runnable else "plan-only" + print(f"scope={composition.scope} status={status} nodes={len(plan)}") + for index, item in enumerate(plan, 1): + print(f"{index:03d} {item.node.name}") + return 0 + if not composition.runnable: + sections = ", ".join(composition.unwired_sections) + raise VerifyGraphError( + f"scope {composition.scope!r} is plan-only; unwired sections: {sections}" + ) + base = graph.workspace / ".artifacts" / "graph" / graph.name + summary = graph_driver.GraphRunner( + graph, base / "logs", base / "state", max_workers=args.jobs + ).run() + for item in summary.plan: + result = summary.results[item.node.name] + print( + f"{result.status:9} {result.name} {result.duration_seconds:.3f}s " + f"log={result.log_path}" + ) + print(f"total {summary.duration_seconds:.3f}s") + return 0 if summary.succeeded else 1 + except (VerifyGraphError, graph_driver.GraphValidationError) as error: + print(f"verify graph error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/copy_dlls.cmd b/copy_dlls.cmd deleted file mode 100644 index e12a55d..0000000 --- a/copy_dlls.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -pushd %~dp0build\%1 -set MODULES_DIR=%DEBUGFARHOME%\Plugins\Observer\modules -copy /Y *.so %MODULES_DIR% || exit 1 -copy /Y *.pdb %MODULES_DIR% -popd \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 498bf9e..30550d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,13 @@ # Project documentation +- [Current build-system handoff](current-status.md) — latest verified status, constraints, open work, and the exact + continuation order for a new chat. - [Current code deep dive](code-deep-dive.md) — architecture, format implementations, defects, and technical risks found during the static review. +- [Build system and engineering workflow](build-system.md) — MSBuild, toolchain, tests, analysis, coverage, fuzzing, and packaging. +- [High-assurance software methodology](critical-software-methodology.md) — TDD, architecture and ABI boundaries, ownership, parser safety, and verification policy. +- [Autonomous build-system work log](autonomous-work-log.md) — temporary decisions, doubts, and verification evidence for review. - [GARbro module plan](garbro.md) - [QuickBMS module plan](quickbms.md) - [Unity module plan](unity.md) -The repository-level [`README.md`](../README.md) remains the public project entry point. Agent instructions remain in [`CLAUDE.md`](../CLAUDE.md). +The repository-level [`README.md`](../README.md) remains the public project entry point. Agent instructions remain in [`AGENTS.md`](../AGENTS.md). diff --git a/docs/autonomous-work-log.md b/docs/autonomous-work-log.md new file mode 100644 index 0000000..751ed20 --- /dev/null +++ b/docs/autonomous-work-log.md @@ -0,0 +1,96 @@ +# Autonomous build-system work log + +This file records decisions, assumptions, unresolved questions, and verification evidence from the autonomous work +session started on 2026-08-01. It is intentionally a review log rather than permanent user documentation; discuss and +either accept, revise, or remove its entries after review. + +## Fixed requirements + +- The shipped modules are native MSVC binaries for x86, x64, and ARM64. +- Release code and dependencies use the static runtime (`/MT`) and ship without redistributable or third-party DLLs. +- The repository build graph uses MSBuild directly; vcpkg ports may use CMake internally. +- The public build entry point works from a clean Windows console without an IDE. +- Production code must reach 100% source line and branch coverage. +- Every meaningful parser surface must have deterministic regression tests and coverage-guided fuzzing. +- Real module DLLs require process-wide leak testing; bounded peak memory is tested separately from leak freedom. +- No local software may be installed or updated during this session. + +## Decisions made during autonomous work + +- Owner decision: keep MSBuild as the native compile/link backend. A Python DAG may orchestrate existing build and + verification leaves, but replacing MSBuild is currently too costly and risky relative to the expected benefit. + Revisit only with build-only evidence of a material native critical-path problem. +- The project registry baseline now follows the installed Scoop vcpkg revision + `9e593bb18ea69cc5095e012465dcd675a822ed0d` (2026-07-29). Of the direct dependencies, only Catch2 changed at this + baseline, from 3.15.2#1 to 3.15.3; zlib 1.3.2, nlohmann/json 3.12.0, and xxHash 0.8.3 remain current. +- Direct use of the zlib C API is isolated in `src/core/compression/zlib_codec.cpp`; all format and test code uses the + repository C++ boundary. The unused zstr adapter was removed after its exception type proved incompatible with the + current Windows sanitizer runtime. Keeping zlib 1.3.2 behind the boundary makes a future backend swap local. +- Parser allocation limits are now explicit: an encoded archive path is capped at 4,092 bytes, derived from the public + 1,024 UTF-16-code-unit Observer ABI buffer, and Zanzarah metadata is capped at 100,000 entries. The external corpus + currently peaks at 2,205 entries, so the entry limit leaves substantial compatibility headroom. Revisit the 100,000 + value if a legitimate corpus example exceeds it; do not remove the bound. + +- After the mandatory build, coverage, fuzzing, and leak gates are complete, parser-core extraction and IWYU + integration are authorized as stretch work during the same autonomous session. +- Parser-core extraction must preserve the public Observer ABI and self-contained per-module release layout; it is a + source boundary statically linked into the existing DLLs, not a new runtime binary. +- Owner decision: after the current build migration and portable parser-core extraction are complete, establish a + first-class WSL2/Linux developer workflow. It should run Mull and the useful Linux sanitizer/tooling surface against + the portable core while the release artifacts continue to be built and verified with MSVC on Windows. +- Missing IWYU is not permission to install software. In that case the repository/CI integration may be prepared, but + the unavailable local execution must be reported explicitly. +- Pickle memo entries currently use deep-copy value semantics because the parser's `unique_ptr` value model cannot + represent Python object identity or cycles. This is correct for the acyclic Ren'Py indexes in scope, but the parser + must not be described as a general cyclic Pickle implementation. +- Fixed-size Observer ABI metadata is always NUL-terminated and safely truncated with `wcsncpy_s(..., _TRUNCATE)`. + Overlong descriptive metadata no longer rejects an otherwise valid archive. +- Two ineffective defensive catches were removed: the `GetItem` runtime-error catch could not be reached after the + explicit out-of-range path, and zstr did not report a beyond-EOF seek through the attempted `ios_base::failure` + catch. Actual parser/read failures remain mapped at the ABI boundary. + +## Doubts and items for morning review + +- Current BinSkim output is clean at error level but emits BA2027 for all three modules because their PDBs do not embed + SourceLink metadata. Adding SourceLink would improve post-release debugging, but doing it correctly requires + commit-specific URL mapping and a decision about publishing source-linked PDBs; it is not being silently suppressed. +- `vcpkg.json` declares `LGPL-3.0-or-later`, while the repository root license and README identify the overall project + as GPLv3. This predates the build-system work. The manifest value may be inaccurate, but changing licensing metadata + needs an explicit owner decision rather than an autonomous guess. +- One ABI coverage test deliberately uses a host progress callback that throws `std::runtime_error` so the DLL's + best-effort exception mapping is exercised. Throwing a C++ exception through a DLL callback is not a supported host + contract, especially with independently linked `/MT` CRT instances. Decide whether to document callbacks as + non-throwing and keep this defensive catch, or introduce an internal failure seam so the branch is tested without a + cross-boundary exception. +- The large `M:\observer\_test` corpus was inspected read-only for representative format variants, but the required + hermetic suite does not execute its multi-gigabyte contents. Generated fixtures cover the supported RPA 2.0, RPA 3.0, + RGSS3A, and Zanzarah PAK variants; the real corpus remains an opt-in compatibility/stress run. +- Native ARM64 CI uses the hosted `windows-11-arm` label. The current environment discovery intentionally invokes the + amd64 MSBuild host and cross compiler, which should run under Windows ARM64 x64 emulation; the first hosted run is the + final proof of that runner/toolchain assumption. +- There is no separate `RelWithDebInfo` configuration. `Release` is the single shippable configuration and combines + `/O2`, `/GL`/`/LTCG`, `/MT`, compiler PDB information, and an explicitly full linker PDB. Symbols are archived + separately per architecture and do not add a runtime or distribution dependency to module DLLs. +- The repository now has a proposed critical-software-inspired policy in `docs/critical-software-methodology.md`. + It makes TDD, 100% first-party line/branch coverage, clean dependency direction, strict C/C++ ABI containment, + RAII-only ownership, bounded untrusted-input processing, and evidence from exact release DLLs mandatory. It does not + claim formal certification. +- Owner decision: decision tables are executable data-driven tests rather than manually maintained review documents; + critical compound conditions require targeted MC/DC reasoning in addition to the global 100% branch gate. +- Owner decision: mutation testing is mandatory. A surviving non-equivalent reached mutant is a test defect; there is + no accepted sub-100 mutation score. The engine remains an implementation decision because Mull requires LLVM and + has no supported native-Windows workflow; mutating the future portable parser core in Linux CI is the leading design. +- Owner decision: certification and coding-standard compliance are out of scope. Core Guidelines, CERT, and JPL ideas + are engineering inputs only; no MISRA or formal safety-standard profile will be maintained. +- Owner decision: do not impose an arbitrary maximum archive-file size. The read-only golden corpus includes RPA up to + 4,468,455,116 bytes, RGSS3A up to 901,714,312 bytes, and Zanzarah PAK up to 774,465,076 bytes. Defensive limits apply + instead to declared fields versus actual remaining input, ABI-representable paths, entry/allocation arithmetic, and + configurable expanded metadata/index budgets. Concrete metadata-budget defaults remain to be derived and tested. + +## Verification ledger + +Commands and their final results will be recorded here after the implementation stabilizes. + +- LLVM production coverage: 969/969 lines, 296/296 branches, 480/480 regions, and 79/79 functions (100% each). +- Deterministic suite at that point: 24 test cases and 539 assertions; x86/x64 MSVC Debug, x64 MSVC ASan, and the x64 + clang-cl coverage gate passed. diff --git a/docs/build-system.md b/docs/build-system.md new file mode 100644 index 0000000..1c37f47 --- /dev/null +++ b/docs/build-system.md @@ -0,0 +1,273 @@ +# Build system and engineering workflow + +## Status + +This document records the agreed target design. The MSBuild migration is implemented alongside it; coverage growth and +additional fuzz targets remain ongoing engineering work. + +## Non-negotiable release contract + +- Windows-only native C++23, compiled with the MSVC `cl.exe` toolchain. +- Release modules are built for x86, x64, and ARM64. +- The MSVC runtime and all third-party libraries are linked statically (`/MT` and static vcpkg triplets). +- A release archive contains no redistributable runtime and no non-system DLL dependencies. +- CMake is not part of this repository's build graph. vcpkg ports may use CMake internally. +- `/clr` is not used. Managed-library integrations require a separate future design that preserves the native, + self-contained release contract. + +## Layers + +```text +build.ps1 / build.cmd stable command-line entry point + | + v +build/build.ps1 environment discovery and task orchestration + | + v +build/ObserverModules.proj aggregate MSBuild targets + | + v +build/projects/*.vcxproj compile/link graph + | + v +cl.exe / link.exe / lib.exe / rc.exe +``` + +PowerShell is not the build engine. The root script is intentionally tiny and contains no source list, compiler flags, +or dependency graph. NMAKE was rejected because it would require hand-maintaining the platform/configuration matrix, +header dependency tracking, vcpkg integration, and project graph while still needing another tool for testing, +coverage, binary auditing, and packaging. + +## Supported commands + +```powershell +.\build.ps1 doctor +.\build.ps1 restore -Arch x64 +.\build.ps1 build -Arch x86,x64,arm64 -Config Release +.\build.ps1 test -Arch x64 -Config Debug +.\build.ps1 source-checks -Arch x86,x64,arm64 +.\build.ps1 compiler-analysis -Arch x64 +.\build.ps1 test-coverage -Arch x64 +.\build.ps1 test-asan -Arch x64 +.\build.ps1 test-ubsan -Arch x64 +.\build.ps1 test-leaks -Arch x64 +.\build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds 60 +.\build.ps1 audit-binaries -Arch x86,x64,arm64 +.\build.ps1 package -Arch x86,x64,arm64 +.\build.ps1 verify -Arch x64 +``` + +`build.cmd` is a convenience shim for `cmd.exe`; both entry points execute the same PowerShell implementation. +Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every +format target. + +`verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs +deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, +the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A +non-native runtime check is reported explicitly as deferred and must be completed on its native CI runner; it is not +reported as executed locally. The verify fuzz phase always covers all four format targets; `-FuzzSeconds` controls its +bounded duration. + +## Configurations + +| Configuration | Purpose | CRT | Distributed | +|---|---|---|---| +| Debug | development and deterministic tests | `/MTd` | no | +| Release | optimized modules and packages | `/MT` | yes | +| Coverage | clang-cl instrumentation and llvm-cov reporting | `/MTd` | no | +| ASan | AddressSanitizer tests against release-only instrumented dependencies | `/MT` | no | +| UBSan | clang-cl undefined-behavior tests | `/MT` | no | +| Fuzz | libFuzzer plus AddressSanitizer | `/MT` | no | +| Leak | UMDH against optimized x64 Release modules and probe | `/MT` | no | + +ASan and fuzzing are supported only on x86 and x64. Their runtime DLLs are test-only dependencies and must never be +copied into release packages. + +## Dependency management + +Library dependencies are declared by `vcpkg.json` in manifest mode. Repository-owned overlay triplets explicitly set +both `VCPKG_CRT_LINKAGE` and `VCPKG_LIBRARY_LINKAGE` to `static` for all three architectures. The vcpkg baseline is +pinned in source control and packages are restored into architecture-specific directories below +`.artifacts/vcpkg_installed`; global vcpkg integration is not required. Separate install roots keep manifest-mode vcpkg +from pruning another architecture while switching targets. + +Normal public commands restore their required dependencies by default. The experimental DAG uses one serial +`restore -RestoreFlavor all` before its parallel fork; `all` prepares both default and ASan dependency flavors (and +skips the unsupported ARM64 ASan flavor). Its later leaf commands pass `-SkipDependencyRestore`, which is reserved for +orchestration that has already completed that prerequisite. Calling `restore` itself with the skip switch is rejected. + +Developer tools are not library dependencies and are discovered by `doctor`: + +- Visual Studio Build Tools 2022 with MSVC x86/x64 and ARM64 tools plus a Windows SDK; +- PowerShell 7.4 or newer; +- vcpkg; +- LLVM tools (`clang-format` and `clang-tidy`); +- Cppcheck; +- PSScriptAnalyzer for PowerShell sources. + +## Compiler and static-analysis policy + +Normal compilation uses `/W4 /WX /permissive-`, the conforming preprocessor, correct `__cplusplus`, SDL checks, and +external-header warning suppression. Release additionally enables optimization and link-time code generation. + +The blocking analysis stack is: + +1. clang-format in check-only mode; +2. MSVC warnings as errors; +3. MSVC native code analysis (`/analyze` through MSBuild); +4. clang-tidy; +5. Cppcheck for the Release configuration of x86, x64, and ARM64; +6. PSScriptAnalyzer for PowerShell files. + +PVS-Studio is explicitly out of scope. Include What You Use is deferred: it is useful for direct/minimal include +hygiene, but its Windows mappings, LLVM version coupling, and false-positive cost do not justify making it a gate yet. +Header self-containment checks and clang-tidy's include diagnostics come first. + +Cppcheck suppressions must be narrow and documented. Repository-wide suppression of a diagnostic is not acceptable. + +### CI analysis evidence + +Analysis gates and report publication are deliberately separate. CI lets each analyzer finish, retains its report even +when the gate fails, and only then enforces the analyzer exit status. Cppcheck emits one SARIF file per architecture. +MSVC `/analyze` keeps one raw SARIF file per first-party project and assigns every run a stable +`msvc-analyze///` identity before the architecture directory is uploaded. The clang-tidy logs produced +by that same compile-only graph are converted into a deduplicated first-party SARIF file with the stable identity +`clang-tidy//`. + +CodeQL uploads through its native action and retains both raw and post-processed SARIF as workflow artifacts. BinSkim +emits and uploads one release-binary SARIF file per architecture. Cppcheck, MSVC, clang-tidy, CodeQL, and BinSkim use +distinct code-scanning categories, so a later upload cannot replace another engine or architecture. Third-party SARIF +uploads are skipped for untrusted fork pull requests where the workflow token cannot write security events; their +reports are still archived as ordinary workflow evidence. + +### Future analysis backlog + +The following tools are deliberately recorded for later work so that they are not lost while the build and test +architecture is being stabilized: + +- **Coverity Scan:** add an independent Windows x64 deep-analysis pass after the repository is eligible and registered + with the service. Run it on a manual or scheduled cadence rather than as a pull-request gate because submissions are + external and rate-limited. Keep its project token in CI secrets and treat findings as an additional engine alongside + CodeQL, not as a replacement for the blocking local analyzers. +- **Infer:** evaluate it only after the portable parser-core boundary can be compiled with Clang on Linux. Start with a + non-blocking parser-core job and publish its SARIF output; do not add a second build path for the Windows DLL adapters + merely to accommodate Infer. +- **Include What You Use:** introduce it after parser/header separation has stabilized. Pin an IWYU release compatible + with the selected LLVM version, generate its compile commands from the canonical build graph, and review suggestions + rather than applying fixes automatically. It checks direct/minimal include ownership, not runtime correctness. +- **CBMC:** use it for small, security-critical units with explicit bounds, especially the bounded byte reader and + offset/size/allocation arithmetic. Write focused harnesses that prove properties such as no overflow and no + out-of-range access; whole-project model checking is not a goal. + +PC-lint Plus has been considered and explicitly rejected for this project. Do not add it to the required toolchain or +CI matrix. + +## Tests + +Catch2 remains the test framework. Tests are split conceptually into: + +- deterministic unit tests for parsers, decryption, size/offset arithmetic, and error handling; +- Observer ABI contract tests, including invalid inputs and cancellation; +- small synthetic archive end-to-end tests committed to the repository; +- the large external golden corpus, selected with `OBSERVER_TEST_CORPUS` or `-Corpus`; +- package smoke tests that load the exact binary that will be distributed. + +An absent external corpus skips only corpus tests; it must not hide failures in deterministic tests. + +### Future parser-core boundary + +The current format parsing code is coupled to the Observer DLL adapter, filesystem operations, and parts of the test +harness. A future refactoring should separate a portable parser core from those concerns. This is an architectural +boundary, not a new shared runtime DLL: the core remains statically linked into each self-contained module, and the +published Observer exports and package layout remain unchanged. + +The intended responsibilities are: + +- the Observer adapter owns the public C ABI, Windows types, module lifetime, callbacks, and error translation; +- the parser core consumes a small bounded byte-source abstraction and produces validated archive metadata and entry + descriptions; +- format-specific implementations remain independent behind a factory or another narrow dispatch boundary; a virtual + base class is optional and should be introduced only if it simplifies the actual call sites and tests; +- extraction I/O is kept outside pure metadata parsing where practical, while decompression and format algorithms + remain testable without loading a plugin DLL. + +This boundary is expected to provide the following benefits: + +- deterministic unit tests for valid, malformed, truncated, and adversarial inputs without creating files or loading + DLLs; +- direct fuzz targets for every format parser instead of fuzzing through Observer or a large integration harness; +- substantially cheaper growth toward 100% branch coverage, with failures attributable to a single parser; +- one implementation of bounded reads, checked offset arithmetic, allocation limits, and entry-range validation; +- portable Clang builds of production parsing code for UBSan and LeakSanitizer on a supported non-Windows host; +- smaller test fixtures, reducing dependence on the multi-gigabyte external golden corpus; +- independent testing of the stable Observer ABI adapter against fake parser results and failure injection. + +The refactoring does not replace end-to-end tests. ABI contract tests, real DLL loading, package smoke tests, and a +smaller compatibility corpus remain necessary because they cover integration behavior that parser-core tests cannot. + +## Coverage + +The analysis-only Coverage configuration uses `clang-cl` source instrumentation and `llvm-cov` to enforce 100% lines +and branches in first-party production modules. Functions and regions remain visible in the report but are not separate +release gates. Test framework, Catch2, vcpkg, generated, and Windows SDK code is excluded. Its reports are evidence +about the deterministic test suite, not release artifacts. + +Canonical correctness tests and all distributed binaries continue to use MSVC `cl.exe`. The same deterministic tests +must pass under MSVC Debug before their clang-cl coverage result is accepted. + +## Sanitizers and fuzzing + +The primary ASan path links production core code into a test executable. A dedicated loader smoke test also exercises +the actual instrumented module DLL inside a controlled host process; real FAR/Observer is not a CI dependency. + +Windows ASan does not detect memory leaks, and a debug-CRT leak check in the test executable cannot account for all +allocations made by separately linked shipping module DLLs. Leak testing is therefore a separate required layer rather +than an ASan option. + +The `test-leaks` operation runs an optimized x64 Release `/MT` probe against the exact Release module DLLs and uses +UMDH from Windows Debugging Tools to compare process-wide heap snapshots. Before measurement it records the loaded +binary paths and hashes, audits their architecture, imports, and exports, and runs an automatic scenario preflight. The +probe then: + +1. load every module and perform an unmeasured application warm-up; +2. let the first UMDH attachment enable process-local allocation stack collection, avoiding persistent/elevated GFlags + registry state, then run another full workload window before accepting a measured baseline; +3. repeatedly exercise successful operations, malformed input, cancellation, read/write failure, and bounded + large/sparse metadata workloads; +4. repeat the same scenario table in a separate mode that loads and unloads each real DLL on every round; +5. take snapshots after multiple measurement windows and retain diffs as test artifacts; +6. fail when allocation stacks or total live heap bytes show sustained growth across consecutive windows. + +The gate must detect a leak slope rather than require a literal zero-byte snapshot difference: the Windows loader, +CRT, symbol engine, and third-party libraries can retain bounded one-time caches. Any allowance must be narrow, +stack-specific, documented, and stable across repeated windows. Debug-CRT checkpoints may provide faster feedback in +unit tests, but they are not accepted as proof that a complete plugin process is leak-free. + +After the parser-core boundary exists, a Clang ASan plus LeakSanitizer job on a supported non-Windows host should check +the same core tests and fuzz regression corpus. It complements UMDH rather than replacing it: LeakSanitizer covers the +portable production logic, while UMDH covers the shipped Windows DLLs, static CRT instances, ABI adapter, and loader +lifecycle. + +Leak freedom and bounded memory consumption are different requirements. A separate memory-budget stress test should +measure peak private bytes while processing synthetic large/sparse archives and verify that archive contents are not +buffered wholesale. The external real-world corpus remains useful as an opt-in local compatibility and stress layer, +but the required CI leak gate must use small, repository-owned fixtures and finish deterministically. + +Fuzzers are standalone executables. They never fuzz through the FAR process. The initial target is the Ren'Py Pickle +parser; archive-index, path, and decompression fuzzers are added as parsing is separated from filesystem I/O. Pull +requests replay every checked-in seed before running each of the four format targets for 30 seconds. The weekly +Saturday 18:17 UTC schedule runs Pickle, Ren'Py, RPG Maker, and Zanzarah for 30 minutes per target (approximately two +hours of coverage-guided execution after the build). Checked-in seeds include minimized regression inputs and compact +representative structures derived from the opt-in external corpus; the external archives themselves are not committed. +Every crash is minimized and committed as a deterministic regression input after triage. + +## Release audit and packaging + +Before packaging, `dumpbin` verifies: + +- the expected x86, x64, or ARM64 PE machine type; +- exactly the Observer exports `LoadSubModule` and `UnloadSubModule`; +- absence of `VCRUNTIME`, `MSVCP`, UCRT, zlib, xxHash, ASan, and other non-system DLL dependencies. + +Packaging is rejected if unexpected DLLs, import libraries, or intermediate files enter the staging directory. PDBs +are published separately from module archives. diff --git a/docs/code-deep-dive.md b/docs/code-deep-dive.md index f341d1d..e16755d 100644 --- a/docs/code-deep-dive.md +++ b/docs/code-deep-dive.md @@ -50,7 +50,7 @@ The common archive layer is a useful separation: format implementations provide - Recognizes the `RPA-` signature and versions 2.0 and 3.0. - Reads the compressed index at the offset stored in the archive header. - RPA-3 offsets and lengths are decoded with the header key. -- Decompresses the whole index into memory with zlib/zstr. +- Decompresses the index through the repository C++ compression boundary backed by statically linked zlib. - Parses the index with the local Pickle subset. - Supports the optional per-entry prefix/header and excludes its length from the body copied from the archive. diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md new file mode 100644 index 0000000..29b84bd --- /dev/null +++ b/docs/critical-software-methodology.md @@ -0,0 +1,168 @@ +# High-assurance software methodology + +Status: proposed repository policy, accepted principles with several owner decisions still open. + +ObserverModules parses untrusted, sometimes very large archives inside another application's process. A malformed +input, memory leak, ABI violation, or unbounded operation can therefore corrupt or exhaust the host. The project adopts +a **critical-software-inspired** engineering method to reduce that risk. This is not a claim of formal MISRA, +DO-178C, IEC 61508, or other safety certification: the project does not currently have an independent verification +organization, a certified toolchain, or the complete requirements-to-binary evidence such a claim would require. + +## Non-negotiable policy + +1. **Requirements and invariants come before implementation.** Each change identifies its observable behavior, + failure behavior, input limits, ownership, and ABI impact. Safety-relevant assumptions must be executable as tests + or explicit assertions where practical. +2. **Strict TDD is the default change protocol.** First produce a focused test that fails for the expected reason, + then make the smallest production change, then refactor with the suite green. Every defect starts with a regression + test. A test that executes a branch without checking behavior is not sufficient. +3. **All first-party production code has 100% line and branch coverage.** Coverage is a necessary completeness signal, + not proof of correctness. Dangerous compound decisions additionally require executable data-driven decision tables + and targeted MC/DC reasoning so each independent condition is shown to affect the result. +4. **Architecture boundaries are enforced.** Observer/FAR and Win32 integration are outer adapters. Archive operations + and format parsers are inner policy. Dependencies point inward; parser code must be independently testable without + loading FAR, Observer, or Win32 UI infrastructure. +5. **The C/C++ boundary is explicit and hostile by default.** It exposes only stable C-compatible layouts, functions, + result codes, and documented ownership. No C++ exception, STL type, RTTI identity, allocator responsibility, or + implicit lifetime crosses the ABI. +6. **C++ resources use deterministic ownership.** Prefer values and standard containers. Every acquired resource is + immediately owned by an RAII handle. Application code contains no naked owning allocation or release. Raw pointers + are non-owning; `std::unique_ptr` is the default polymorphic owner, while `std::shared_ptr` is exceptional and must + express a genuinely shared lifetime. +7. **All work caused by input is bounded.** A parser defines and checks maximum sizes, counts, nesting depth, allocation + budget, and progress conditions before doing expensive work. Integer calculations are checked before narrowing, + seeking, allocating, or indexing. Input-driven recursion is replaced by iteration or given a strict depth bound. + There is no arbitrary whole-archive size ceiling: multi-gigabyte archives are legitimate. Declared structural + fields must fit the actual input, paths must fit the public ABI, and expanded metadata/index data receives a + separately configurable budget so a compact decompression bomb cannot exhaust the host process. +8. **Failures are deterministic and fail closed.** Partial output is not reported as success. Cancellation, callback + failure, malformed input, exhaustion, and I/O errors have tested outcomes. Outermost ABI functions validate inputs, + initialize outputs, catch only at the boundary, and translate failures to the documented result contract. +9. **Evidence is produced by the exact deliverable.** Unit and parser tests may use seams, but ABI integration, + import/export audit, packaging smoke tests, and leak tests exercise the MSVC Release DLLs that are shipped. +10. **A green gate is never manufactured.** Threshold reductions, first-party exclusions, broad suppressions, swallowed + sanitizer failures, or catch-all fuzz targets are prohibited. Any necessary deviation is narrow, justified, + time-bounded where appropriate, and owner-reviewed. + +## C ABI contract + +Every exported function and callback must satisfy all of the following: + +- use `extern "C"`, an explicit calling convention, fixed-width or ABI-defined types, and fixed-layout structures; +- version extensible structures with `StructSize` or an equivalent explicit size contract; +- validate every pointer, buffer length, structure size, enum/range value, and callback before dereference or call; +- initialize output structures and handles before any operation that can fail; +- document who owns every buffer and handle, how long borrowed data remains valid, and who releases a resource; +- never allocate in one CRT and require another module to deallocate it; +- prohibit exceptions escaping either exported functions or host callbacks; translate internal failures once at the + outer boundary and keep that mapping covered by ABI tests; +- preserve the existing symbol names, calling convention, layouts, and result semantics unless an explicitly reviewed + ABI version change is made. + +Compatibility is checked at both source and binary levels: compile-time layout assertions, real-DLL contract tests, +exact export allowlists, import audits, and package smoke tests. + +## C++ ownership and resource rules + +- Prefer values, `std::vector`, `std::string`, and scoped resource wrappers. +- Use `std::span`/`std::string_view` for checked borrowed ranges and references for required non-null objects. +- Use `std::unique_ptr` only when value semantics do not fit, normally for polymorphism or optional ownership. +- Use `std::shared_ptr` only when no single owner can be identified; record the lifetime reason in the design review. +- Wrap `FILE*`, Win32 `HANDLE`/`HMODULE`, archive streams, zlib state, and temporary-file cleanup in move-only RAII + types with non-throwing destructors. +- Do not call owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free` in application C++. Placement new inside + a reviewed low-level resource abstraction is a possible deviation, not a general exception. +- Destructors and cleanup paths must not throw. Move operations should be `noexcept` when their members permit it. +- Avoid mutable globals. If shared state is unavoidable, define its lifetime, synchronization, and reset behavior for + repeated module load/unload cycles. + +These rules follow the C++ Core Guidelines resource-management model: automatic resource handles and RAII, raw +pointers as non-owning views, no naked `new`/`delete`, and `unique_ptr` preferred over shared ownership. + +## Parser safety case + +Each supported format gets a small safety case in tests and, as the parser core is extracted, in its module-level +documentation. At minimum it answers: + +- What identifies the format, and how are truncated or contradictory headers rejected? +- What are the maximum accepted archive size, entry count, path length, nesting depth, metadata/index size, and + decompressed size? Which limits come from the format and which are defensive project limits? +- Which additions, multiplications, casts, seeks, and range calculations can overflow or leave the input bounds? +- Can every loop demonstrate progress and a finite upper bound? Can decompression or parsing amplify tiny input into + excessive CPU, memory, disk, or output? +- How are path traversal, absolute paths, device names, alternate separators, duplicate names, and Unicode conversion + handled before extraction? +- What happens on cancellation, callback failure, short read/write, close/flush failure, partial output, and host + unload/reload? + +Required tests include valid minimal and representative archives, every error category, zero/one/maximum boundaries, +one-past-limit cases, truncation at meaningful byte positions, arithmetic edges, callback failures, cancellation, and +resource cleanup after every failure path. Multi-gigabyte external corpora remain optional compatibility/stress input; +small generated repository fixtures are the deterministic CI contract. + +## Verification ladder + +Every layer finds a different defect class; passing one does not substitute for another. + +1. **Fast deterministic tests:** parser/unit tests, common archive-operation tests, and ABI contract tests. +2. **Structural coverage:** 100% LLVM line and branch coverage over first-party production code, plus review of tests + that reach each branch. +3. **Mutation testing:** mutate first-party parser/application logic and require every non-equivalent reached mutant to + be killed. Surviving mutants are fixed with stronger behavioral tests; they are not hidden by lowering a percentage + threshold. Mutation reports are retained as CI evidence. +4. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on x86, x64, and ARM64 where the runner is native. +5. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, CodeQL, and PowerShell + analysis. Diagnostics are fixed or narrowly justified, never globally muted. +6. **Dynamic analysis:** MSVC AddressSanitizer locally/CI where supported; clang-cl AddressSanitizer and + UndefinedBehaviorSanitizer as independent diagnostic builds; UMDH across repeated real-DLL operations and repeated + load/unload cycles. Peak private bytes and resource budgets are checked separately from leak growth. +7. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated + seed corpora, bounded input/resources, persisted crash artifacts, and regression tests for every confirmed defect. +8. **Binary and package assurance:** exact exports, forbidden imports, `/MT` runtime audit, BinSkim, PDB archive audit, + archive-content allowlists, and smoke tests of the packaged DLL bytes. +9. **Release evidence:** clean-checkout build, pinned dependencies, full gate results, hashes/artifacts, and a reviewed + decision/deviation log. + +Coverage and fuzzing must not catch allocation exhaustion or unexpected exceptions merely to keep running. A crash, +sanitizer report, timeout, leak, or resource-budget violation is a finding and becomes a minimized regression test. + +## Change protocol and traceability + +For each non-trivial change, preserve this chain in the issue/commit, test names, and reports: + +`requirement or hazard -> failing test -> implementation -> coverage -> analysis -> dynamic/fuzz evidence -> binary` + +The repository does not need bureaucratic documents for trivial refactors, but a reviewer must be able to answer why +the change exists, which failure it prevents, which test demonstrates it, which binary contains it, and whether it +changes an ABI, parser limit, dependency, or accepted risk. + +Security- and reliability-relevant deviations are recorded in `docs/autonomous-work-log.md` until accepted and moved to +a durable decision record. Analyzer suppressions include the rule, exact scope, rationale, and a test or other evidence +that covers the residual risk. + +## Standards adapted, not claimed + +- **C++ Core Guidelines:** normative baseline for ownership, RAII, interfaces, bounds-aware views, and simplicity. +- **SEI CERT C++:** secure-coding review source for declarations, integers, containers, strings, memory, I/O, error + handling, object lifetime, concurrency, and miscellaneous security hazards. +- **JPL Power of Ten:** adopt reviewable control flow, bounded loops, no unbounded recursion, small cohesive functions, + assertions/contracts, minimal preprocessor use, and warnings/static analysis. Its strict C-oriented prohibition on + dynamic allocation is adapted to bounded RAII allocation because archive metadata is inherently variable-sized. +- **MISRA and formal safety standards:** they can inspire individual engineering practices, but compliance and + certification are explicitly out of scope. The repository will not maintain a MISRA profile or claim DO-178C, + IEC 61508, ISO 26262, or similar status. + +## Decisions to settle with the owner + +1. **Internal error model:** keep typed C++ exceptions inside the core and translate them only at the ABI, or migrate + fallible parser/application operations toward an explicit result type such as `std::expected`? Either choice must + preserve RAII and prohibit exceptions crossing the C boundary. +2. **Resource budgets:** choose concrete archive, entry-count, path, index, decompressed-output, nesting, CPU/time, and + memory ceilings per format, including whether callers may configure them. +3. **Shared ownership:** forbid `std::shared_ptr` entirely in first-party code unless an ADR is approved, or permit it + with a local lifetime rationale? +4. **Mutation engine:** select and pin a practical engine. Mull is LLVM-based and produces machine-readable reports, + but does not provide a supported native-Windows workflow; the current leading design is to mutate the portable + parser core under Clang in Linux CI while keeping all shipped binaries MSVC-built and independently tested. +5. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become + mandatory release gates. diff --git a/docs/current-status.md b/docs/current-status.md new file mode 100644 index 0000000..f45acf0 --- /dev/null +++ b/docs/current-status.md @@ -0,0 +1,148 @@ +# Current build-system handoff + +This is the working handoff for continuing the MSBuild/toolchain migration in a new Codex chat. It records the +repository state verified on 2026-08-01, what is implemented, and what remains. Treat concrete command evidence below +as authoritative; older counts in `autonomous-work-log.md` are historical snapshots. + +## Repository state + +- Repository: `C:\Users\Roma\Dev\ObserverModules` +- Branch: `codex/msbuild-toolchain` +- The worktree intentionally contains the complete migration and is not yet committed or pushed. +- `CLAUDE.md` has been replaced by repository-level `AGENTS.md`. +- The old CMake/IDE entry points are being removed. CMake remains acceptable only inside vcpkg ports. +- Do not create a background Goal for this work. In the previous chat Goal cards repeatedly became unavailable. +- Do not install or update software. Ask the owner when another tool is required. +- Keep verbose command output in `.artifacts/*.log` and report only concise results in chat; verbose dynamic-check + output repeatedly triggered a Codex UI display filter, although commands and filesystem changes continued normally. + +## Fixed engineering requirements + +- Native MSVC release modules for x86, x64, and ARM64. +- Static CRT (`/MT`) and static third-party dependencies; release packages must not require the VC redistributable or + adjacent dependency DLLs. +- The console entry point is `build.ps1`/`build.cmd`, with direct MSBuild project files under `build/`. +- `Release` is the shippable optimized-with-symbols configuration: `/O2`, `/GL`, `/LTCG`, `/MT`, `/Zi`, and a full + linker PDB. There is intentionally no separate `RelWithDebInfo` configuration. +- TDD, 100% production line and branch coverage, mutation testing, deterministic tests, format-aware dynamic testing, + leak checks, binary inspection, and hermetic CI. +- Clean architecture and strict C/C++ boundaries. Application and test code must not call a C API directly; a C API is + contained in a dedicated C++ adapter. Owning manual allocation in C++ is forbidden; use RAII and smart pointers. +- No compliance or certification profile is planned. + +## Implemented system + +- Direct MSBuild graph and the console commands documented by `build.ps1 help`: + `doctor`, `restore`, `build`, `test`, `source-checks`, `compiler-analysis`, `test-coverage`, `test-asan`, + `test-ubsan`, `test-leaks`, `fuzz`, `audit-binaries`, `package`, `verify`, and `clean`. +- Static vcpkg triplets for x86/x64/ARM64 plus sanitizer-specific variants. The manifest baseline is + `9e593bb18ea69cc5095e012465dcd675a822ed0d`; current direct versions are Catch2 3.15.3, + nlohmann/json 3.12.0#2, xxHash 0.8.3, and zlib 1.3.2#1. +- clang-format, clang-tidy, Cppcheck, PSScriptAnalyzer, MSVC `/analyze`, ASan, clang-cl UBSan, LLVM coverage, libFuzzer, + UMDH scaffolding, dumpbin, BinSkim, and GitHub CodeQL workflow scaffolding. +- One combined PDB archive per architecture is implemented in the packaging source. +- Hermetic generated fixtures cover Ren'Py RPA 2.0/RPA 3.0, RPG Maker RGSS3A, and Zanzarah PAK. The multi-gigabyte + corpus at `M:\observer\_test` is optional compatibility/stress input, not a mandatory CI checkout. +- Format-aware executable targets exist for Pickle, Ren'Py, RPG Maker, and Zanzarah. `-FuzzTarget` can select one or + all targets. +- Resource bounds exist for ABI-representable paths, entry metadata, actual remaining input, and expanded Ren'Py + metadata. There is no arbitrary whole-archive size limit. +- zstr has been removed. Production zlib calls are contained in `src/core/compression/zlib_codec.cpp`; fixture + compression is separately contained in `src/tests/support/zlib_fixture.cpp`. Other production/test code sees only + C++ APIs, so a later decompressor replacement is local. +- Ren'Py, RPG Maker, and Zanzarah share `observer::io::bounded_stream` for checked positioning and exact reads. + +## Latest verified evidence + +All commands below completed successfully after the latest Pickle regression fix. + +| Gate | Result | +| --- | --- | +| MSVC x64 Debug deterministic suite | 35 test cases, 639 assertions | +| LLVM production line coverage | 1126/1126, 100% | +| LLVM production branch coverage | 358/358, 100% | +| LLVM production functions | 91/91, 100% | +| Short Pickle format run | 151,692 executions in 15 seconds | +| Short Ren'Py format run | 345,037 executions in 15 seconds | +| Short RPG Maker format run | 219,383 executions in 15 seconds | +| Short Zanzarah format run | 418,998 executions in 15 seconds | + +The short all-format command returned exit code 0. Detailed local evidence is in: + +- `.artifacts/regression-test.log` +- `.artifacts/coverage-check.log` +- `.artifacts/format-check.log` +- `.artifacts/coverage/x64/coverage.json` +- `.artifacts/coverage/x64/coverage.lcov` + +The dynamic Pickle run found an invalid mark-position transition after the first 100% coverage result. A minimized +unit regression now requires the parser to reject it as a normal parse error, `pop_to_mark()` validates its invariant, +and both 100% coverage and the all-format short run passed again. + +## Required next work, in order + +1. Run `source-checks` and fix clang-format, Cppcheck, and PSScriptAnalyzer findings introduced by the latest changes. + Do not weaken rules to make the gate green. +2. Check in a minimized seed for the new Pickle regression, replay every checked-in seed, then define a longer + all-format CI schedule. Seed the four targets with representative examples derived from the external corpus without + committing the multi-gigabyte corpus. +3. Rework `test-leaks` and `leak-probe.vcxproj` to test the exact optimized x64 `Release` (`/MT`) DLLs. The current CI + leak job is mandatory but the script still hard-codes a Debug probe. Expand cases beyond small happy paths to cover + parse failure, cancellation, read/write failure, repeated DLL lifecycle, and large/sparse metadata workloads. +4. Add package smoke verification: unpack each produced ZIP, assert its exact contents, load the DLL from the unpacked + package, and exercise the Observer API. Run packaging for x86/x64/ARM64 to prove the new one-PDB-ZIP-per-architecture + layout. Ensure each module package contains only its own applicable third-party documents. +5. Make `verify` a truthful aggregate gate. It currently omits coverage, sanitizers, leak checks, all-format runs, and + package smoke. Split build-only ARM64 verification from executable tests when the host cannot run ARM64 binaries; + `verify -Arch all` must not fail merely because an x64 host cannot execute ARM64. +6. Run the clean build/test matrix: MSVC x86 and x64 Debug/Release tests; ARM64 Debug/Release builds locally and tests on + the native GitHub runner. Then run ASan x86/x64 and clang-cl UBSan x64 after the latest parser changes. +7. Run `/analyze` and clang-tidy for modules, tests, format targets, and the leak probe on all supported architectures. + Currently the aggregate analysis graph omits the format targets and leak probe. +8. Finish GitHub report integration. MSVC analysis, Cppcheck, CodeQL, and BinSkim should upload SARIF. clang-tidy still + needs a reliable SARIF conversion/upload path. Preserve mandatory job semantics even when reports use + `continue-on-error` for artifact collection. +9. Run `audit-binaries` for x86/x64/ARM64 and verify the exact Release DLLs with dumpbin and BinSkim: `/MT`, expected + architecture and mitigations, no debug CRT, no unexpected imports, no adjacent dependency DLLs. BA2027 about absent + SourceLink remains an explicit owner decision, not a silently suppressed result. +10. Implement mutation testing. The leading approach is to extract a portable parser core and run Mull in Linux CI; + no reached non-equivalent surviving mutant is acceptable. Do not install Mull locally without owner approval. +11. Update the permanent docs with final evidence, inspect the complete diff, commit on `codex/msbuild-toolchain`, push, + and report any CI-only assumptions that still require the first GitHub run. + +After the mandatory gates above, the authorized stretch work is IWYU integration and extraction of a portable parser +core statically linked into the existing module DLLs. This must not add a runtime DLL or change the public Observer ABI. +After that core boundary is established, add a supported WSL2/Linux developer workflow for mutation testing and the +Linux sanitizer/tooling surface that Windows cannot provide. The WSL2 build is an additional quality backend for the +portable core, not a replacement for the shipping MSVC/Windows DLL matrix. + +## Known decisions and open questions + +- MSBuild remains the native compile/link backend. The Python DAG pilot may replace outer verification orchestration + only; it must not grow into a direct `cl.exe`/`link.exe` driver. Reconsider a Ninja-backed native prototype only if + build-only measurements show at least a 15% critical-path opportunity or MSBuild no-op evaluation is both above two + seconds and above 25% of build-only time. +- Owner decision: the production DAG must expose the smallest safe independent work units instead of wrapping whole + `build.ps1` gates. This includes project/configuration builds, test shards, analyzer project/translation-unit work, + SARIF normalization and merge, fuzz targets, leak scenarios/modes, audit tools/modules, and package smoke units. + Resource pools and isolated write roots—not artificial phase-wide dependencies—must constrain parallelism. +- Keep zlib 1.3.2 for now behind the C++ adapter. Replacing it remains possible, but the removed zstr layer—not zlib + itself—was the source of the earlier incompatibility. +- The exact metadata budgets are safety controls, not whole-file limits. Revisit values using real corpus statistics, + but do not remove checked arithmetic and actual-input bounds. +- `vcpkg.json` says `LGPL-3.0-or-later`, while the repository README/license identify GPLv3. The owner must decide the + correct manifest metadata. +- A test currently exercises defensive handling of a host callback that throws across the DLL boundary. Decide whether + callbacks should instead be documented as non-throwing and tested through an internal seam. +- UBSan instruments first-party clang-cl code but not the MSVC-built vcpkg dependencies. This limitation must be stated + accurately in CI evidence. +- MSan/TSan are not mandatory for the current Windows plugin architecture. Reconsider TSan only if meaningful + concurrent parser code is introduced; use UMDH/ASan and bounded-memory tests for the current memory requirements. + Once the portable parser core and WSL2 workflow exist, evaluate Linux ASan/UBSan/LSan routinely and add MSan/TSan + only where their platform and program-model prerequisites make their results meaningful. + +## Locally available tools reported by the owner + +The owner installed Cppcheck, PSScriptAnalyzer, LLVM/clang tools, MSVC AddressSanitizer, Spectre libraries, and BinSkim +(on `%PATH%`). vcpkg is managed exclusively through Scoop. Do not replace or self-update this setup. IWYU and a local +CodeQL CLI have not been established; GitHub Actions may use the official CodeQL action without a local install. diff --git a/licenses/IX.txt b/licenses/IX.txt new file mode 100644 index 0000000..6432aae --- /dev/null +++ b/licenses/IX.txt @@ -0,0 +1,22 @@ +IX build system +Source: https://github.com/pg83/ix +Revision: 66726a904152246fbef8b27e26e878840f6d7fb7 + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/zstr.txt b/licenses/zstr.txt deleted file mode 100644 index 3c33ea6..0000000 --- a/licenses/zstr.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015 Matei David, Ontario Institute for Cancer Research - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/api.h b/src/api.h index 0fa0292..c7ff66f 100644 --- a/src/api.h +++ b/src/api.h @@ -23,16 +23,6 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst #define _WIN32_WINNT 0x0600 #endif -#ifndef _WIN32_WINDOWS -// Specifies that the minimum required platform is Windows 98. -#define _WIN32_WINDOWS 0x0410 -#endif - -#ifndef _WIN32_IE -// Specifies that the minimum required platform is Internet Explorer 7.0. -#define _WIN32_IE 0x0700 -#endif - // Exclude rarely-used stuff from Windows headers. #define WIN32_LEAN_AND_MEAN @@ -41,7 +31,7 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst #define MODULE_EXPORT __stdcall // Extract progress callbacks -typedef int (CALLBACK *ExtractProgressFunc)(HANDLE, __int64); +typedef int(CALLBACK *ExtractProgressFunc)(HANDLE, __int64); #pragma pack(push, 1) @@ -93,15 +83,15 @@ struct ExtractOperationParams ExtractProcessCallbacks Callbacks; }; -typedef int (MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info); +typedef int(MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info); -typedef int (MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage); +typedef int(MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage); -typedef void (MODULE_EXPORT *CloseStorageFunc)(HANDLE storage); +typedef void(MODULE_EXPORT *CloseStorageFunc)(HANDLE storage); -typedef int (MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info); +typedef int(MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info); -typedef int (MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params); +typedef int(MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params); struct module_cbs { @@ -114,10 +104,10 @@ struct module_cbs struct ModuleLoadParameters { - //IN + // IN size_t StructSize; const wchar_t *Settings; - //OUT + // OUT GUID ModuleId; DWORD ModuleVersion; DWORD ApiVersion; @@ -127,12 +117,12 @@ struct ModuleLoadParameters #pragma pack(pop) // Function that should be exported from modules -typedef int (MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *); +typedef int(MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *); -typedef void (MODULE_EXPORT *UnloadSubModuleFunc)(void); +typedef void(MODULE_EXPORT *UnloadSubModuleFunc)(void); -#define MAKEMODULEVERSION(mj,mn) ((mj << 16) | mn) -#define STRBUF_SIZE(x) ( sizeof(x) / sizeof(x[0]) ) +#define MAKEMODULEVERSION(mj, mn) (((mj) << 16) | (mn)) +#define STRBUF_SIZE(x) (sizeof(x) / sizeof((x)[0])) // Open storage return results #define SOR_INVALID_FILE 0 diff --git a/src/archive.cpp b/src/archive.cpp index e284497..f5e38d1 100644 --- a/src/archive.cpp +++ b/src/archive.cpp @@ -9,9 +9,8 @@ namespace archive { - archive::archive(std::unique_ptr extractor) + archive::archive(std::unique_ptr extractor) : extractor_(std::move(extractor)) { - extractor_ = std::move(extractor); } bool starts_with_bytes(std::span data, std::span signature) noexcept @@ -50,7 +49,7 @@ namespace archive throw read_error(); } - for (const auto &file: files_) { + for (const auto &file : files_) { std::ranges::replace(file->path, '/', '\\'); } } @@ -73,46 +72,42 @@ namespace archive } output.exceptions(std::ofstream::failbit | std::ofstream::badbit); - constexpr int64_t buffer_size = 128 * 1024; + constexpr int64_t buffer_size = 128LL * 1024; std::vector buffer(buffer_size); - if (!file.header.empty()) { - output.write(file.header.data(), std::ssize(file.header)); - } - try { - stream_->seekg(file.offset); - } catch (std::ios_base::failure &) { - throw read_error(); - } - - uint32_t magic = file.magic; - int64_t bytes_left = file.compressed_body_size_in_bytes; - while (bytes_left > 0) { - const auto chunk_size = static_cast(std::min(bytes_left, buffer_size)); + if (!file.header.empty()) { + output.write(file.header.data(), std::ssize(file.header)); + } try { - stream_->read(buffer.data(), chunk_size); + stream_->seekg(file.offset); } catch (std::ios_base::failure &) { throw read_error(); } - buffer.resize(static_cast(chunk_size)); - magic = extractor_->decrypt(magic, buffer); + uint32_t magic = file.magic; + int64_t bytes_left = file.compressed_body_size_in_bytes; + while (bytes_left > 0) { + const auto chunk_size = static_cast(std::min(bytes_left, buffer_size)); - try { - output.write(buffer.data(), buffer.size()); - } catch (std::ios_base::failure &) { - throw write_error(); - } + try { + stream_->read(buffer.data(), chunk_size); + } catch (std::ios_base::failure &) { + throw read_error(); + } - bytes_left -= chunk_size; + buffer.resize(static_cast(chunk_size)); + magic = extractor_->decrypt(magic, buffer); + output.write(buffer.data(), static_cast(buffer.size())); - try { + bytes_left -= chunk_size; report_progress(chunk_size); - } catch (user_interrupt &) { - return; } + + output.close(); + } catch (std::ios_base::failure &) { + throw write_error(); } } -} +} // namespace archive diff --git a/src/archive.h b/src/archive.h index 86fdecf..04ed8d6 100644 --- a/src/archive.h +++ b/src/archive.h @@ -11,31 +11,31 @@ namespace archive { class read_error final : public std::runtime_error { - public: - read_error(): runtime_error("") + public: + read_error() : runtime_error("") { } }; class write_error final : public std::runtime_error { - public: - write_error(): runtime_error("") + public: + write_error() : runtime_error("") { } }; class user_interrupt final : public std::runtime_error { - public: - user_interrupt(): runtime_error("") + public: + user_interrupt() : runtime_error("") { } }; class archive final { - public: + public: explicit archive(std::unique_ptr extractor); extractor::archive_info open(const std::filesystem::path &path, const std::span &data); @@ -47,9 +47,9 @@ namespace archive void extract_file(size_t index, const std::filesystem::path &path, const std::function &report_progress) const; - private: + private: std::unique_ptr extractor_; std::unique_ptr stream_; - std::vector > files_; + std::vector> files_; }; -} +} // namespace archive diff --git a/src/core/archive_limits.h b/src/core/archive_limits.h new file mode 100644 index 0000000..2f48261 --- /dev/null +++ b/src/core/archive_limits.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace observer::archive_limits +{ + // The Observer ABI exposes 1024 UTF-16 code units. Four UTF-8 bytes per usable code unit is a safe allocation cap. + inline constexpr std::size_t max_path_bytes = std::size_t{4} * (1024 - 1); + + // The repository corpus currently peaks at 2,205 entries. This leaves ample compatibility headroom while keeping + // a corrupt count from driving an unbounded allocation. + inline constexpr std::size_t max_entry_count = 100'000; +} // namespace observer::archive_limits diff --git a/src/core/compression/zlib_codec.cpp b/src/core/compression/zlib_codec.cpp new file mode 100644 index 0000000..86aa6bd --- /dev/null +++ b/src/core/compression/zlib_codec.cpp @@ -0,0 +1,92 @@ +#include "zlib_codec.h" + +#include +#include + +#include + +namespace observer::compression +{ + namespace + { + class inflate_context final + { + public: + inflate_context() + { + if (inflateInit(&stream_) != Z_OK) { + throw error("Failed to initialize zlib decompression"); + } + } + + ~inflate_context() + { + static_cast(inflateEnd(&stream_)); + } + + inflate_context(const inflate_context &) = delete; + inflate_context &operator=(const inflate_context &) = delete; + inflate_context(inflate_context &&) = delete; + inflate_context &operator=(inflate_context &&) = delete; + + [[nodiscard]] z_stream &stream() noexcept + { + return stream_; + } + + private: + z_stream stream_{}; + }; + + [[noreturn]] void throw_zlib_error(const char *operation, const z_stream &stream) + { + auto message = std::string(operation); + if (stream.msg != nullptr) { + message.append(": ").append(stream.msg); + } + throw error(message); + } + } // namespace + + std::vector decompress_zlib(const std::span input, const std::size_t max_output_bytes) + { + inflate_context context; + auto &stream = context.stream(); + std::vector output; + std::vector chunk(std::size_t{64} * 1024); + std::size_t input_offset = 0; + + while (true) { + if (stream.avail_in == 0 && input_offset < input.size()) { + const auto input_size = + std::min(input.size() - input_offset, static_cast(std::numeric_limits::max())); + stream.next_in = reinterpret_cast(const_cast(input.data() + input_offset)); + stream.avail_in = static_cast(input_size); + input_offset += input_size; + } + + stream.next_out = reinterpret_cast(chunk.data()); + stream.avail_out = static_cast(chunk.size()); + const auto result = inflate(&stream, Z_NO_FLUSH); + const auto produced = chunk.size() - stream.avail_out; + if (produced > max_output_bytes - output.size()) { + throw error("zlib output exceeds the configured metadata budget"); + } + output.insert(output.end(), chunk.begin(), chunk.begin() + static_cast(produced)); + + if (result == Z_STREAM_END) { + const auto consumed_input = input_offset - stream.avail_in; + if (consumed_input != input.size()) { + throw error("Trailing data after zlib stream"); + } + return output; + } + if (result == Z_BUF_ERROR) { + throw error("Truncated zlib stream"); + } + if (result != Z_OK) { + throw_zlib_error("zlib decompression failed", stream); + } + } + } +} // namespace observer::compression diff --git a/src/core/compression/zlib_codec.h b/src/core/compression/zlib_codec.h new file mode 100644 index 0000000..5071d2c --- /dev/null +++ b/src/core/compression/zlib_codec.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include + +namespace observer::compression +{ + class error final : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; + + [[nodiscard]] std::vector decompress_zlib(std::span input, + std::size_t max_output_bytes); +} // namespace observer::compression diff --git a/src/core/io/bounded_stream.cpp b/src/core/io/bounded_stream.cpp new file mode 100644 index 0000000..bcaa4cb --- /dev/null +++ b/src/core/io/bounded_stream.cpp @@ -0,0 +1,77 @@ +#include "bounded_stream.h" + +#include + +namespace observer::io +{ + bounded_stream::bounded_stream(std::istream &stream) : stream_(stream) + { + try { + const auto original = stream_.tellg(); + stream_.seekg(0, std::ios::end); + const auto end = stream_.tellg(); + stream_.seekg(original); + if (original < 0 || end < original) { + throw read_error(); + } + size_ = static_cast(end); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + } + + std::streamoff bounded_stream::size() const noexcept + { + return size_; + } + + std::streamoff bounded_stream::position() + { + try { + const auto result = stream_.tellg(); + if (result < 0 || result > size_) { + throw read_error(); + } + return static_cast(result); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + } + + std::streamoff bounded_stream::remaining() + { + return size_ - position(); + } + + void bounded_stream::seek_absolute(const std::streamoff offset) + { + if (offset < 0 || offset > size_) { + throw read_error(); + } + try { + stream_.seekg(offset); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + if (!stream_) { + throw read_error(); + } + } + + void bounded_stream::read_exact(char *destination, const std::size_t byte_count) + { + if (byte_count > static_cast(std::numeric_limits::max()) || + byte_count > static_cast(remaining())) { + throw read_error(); + } + const auto stream_size = static_cast(byte_count); + try { + stream_.read(destination, stream_size); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + if (stream_.gcount() != stream_size) { + throw read_error(); + } + } +} // namespace observer::io diff --git a/src/core/io/bounded_stream.h b/src/core/io/bounded_stream.h new file mode 100644 index 0000000..d5b487c --- /dev/null +++ b/src/core/io/bounded_stream.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace observer::io +{ + class read_error final : public std::runtime_error + { + public: + read_error() : std::runtime_error("bounded stream read failed") + { + } + }; + + class bounded_stream final + { + public: + explicit bounded_stream(std::istream &stream); + + [[nodiscard]] std::streamoff size() const noexcept; + [[nodiscard]] std::streamoff position(); + [[nodiscard]] std::streamoff remaining(); + void seek_absolute(std::streamoff offset); + void read_exact(char *destination, std::size_t byte_count); + + template + requires std::is_trivially_copyable_v + [[nodiscard]] Value read_trivial() + { + Value value{}; + read_exact(reinterpret_cast(&value), sizeof(value)); + return value; + } + + private: + std::istream &stream_; + std::streamoff size_ = 0; + }; +} // namespace observer::io diff --git a/src/dll.cpp b/src/dll.cpp index 389606f..9a70619 100644 --- a/src/dll.cpp +++ b/src/dll.cpp @@ -7,16 +7,38 @@ #include #include -void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len) +#ifdef _DEBUG +#include +#include +#endif + +namespace { - if (wcscpy_s(destination, max_len, source.c_str()) != 0) { - throw std::runtime_error("CopyString failed"); +#ifdef _DEBUG + void configure_debug_crt() noexcept + { + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); } +#endif +} // namespace + +void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len) noexcept +{ + static_cast(wcsncpy_s(destination, max_len, source.c_str(), _TRUNCATE)); } extern "C" int MODULE_EXPORT OpenStorage(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info) { - if (storage == nullptr) return SOR_INVALID_FILE; + if (storage == nullptr || info == nullptr || params.FilePath == nullptr) + return SOR_INVALID_FILE; + + *storage = nullptr; const std::filesystem::path path(params.FilePath); @@ -58,7 +80,7 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage) return FALSE; } - const auto archive = static_cast(storage); + auto *archive = static_cast(storage); try { archive->prepare_files(); } catch (std::runtime_error &) { @@ -72,11 +94,11 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage) extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItemInfo *item_info) { - if (storage == nullptr || item_index < 0) { + if (storage == nullptr || item_index < 0 || item_info == nullptr) { return GET_ITEM_ERROR; } - const auto archive = static_cast(storage); + const auto *archive = static_cast(storage); try { const auto &file = archive->get_file(item_index); const auto header_size = std::ssize(file.header); @@ -93,28 +115,25 @@ extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItem } } catch (std::out_of_range &) { return GET_ITEM_NOMOREITEMS; - } catch (std::runtime_error &) { - return GET_ITEM_ERROR; } return GET_ITEM_OK; } -// ReSharper disable once CppParameterMayBeConst extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams params) { - if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr) { + if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr || + params.Callbacks.FileProgress == nullptr) { return SER_ERROR_SYSTEM; } - auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read) - { + auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read) { if (!callbacks.FileProgress(callbacks.signalContext, bytes_read)) { throw archive::user_interrupt(); } }; - const auto archive = static_cast(storage); + const auto *archive = static_cast(storage); try { archive->extract_file(params.ItemIndex, params.DestPath, report_progress); } catch (archive::user_interrupt &) { @@ -134,6 +153,13 @@ extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams extern "C" int MODULE_EXPORT LoadSubModule(ModuleLoadParameters *LoadParams) noexcept { +#ifdef _DEBUG + configure_debug_crt(); +#endif + if (LoadParams == nullptr) { + return FALSE; + } + const auto [id, major_version, minor_version] = extractor::get_version_info(); const auto [id1, id2, id3, id4] = id; LoadParams->ModuleId = {id1, id2, id3, {id4[0], id4[1], id4[2], id4[3], id4[4], id4[5], id4[6], id4[7]}}; diff --git a/src/fuzz/archive.cpp b/src/fuzz/archive.cpp new file mode 100644 index 0000000..bc4fbac --- /dev/null +++ b/src/fuzz/archive.cpp @@ -0,0 +1,51 @@ +#include "../modules/extractor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::uint32_t initial_magic(const std::uint8_t *data, const std::size_t size) noexcept + { + std::uint32_t magic = 0; + if (size > 0) { + std::memcpy(&magic, data, std::min(size, sizeof(magic))); + } + return magic; + } +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size) +{ + extractor::extractor parser; + + std::vector body(size); + if (size > 0) { + std::memcpy(body.data(), data, size); + } + static_cast(parser.decrypt(initial_magic(data, size), body)); + + const std::string bytes(reinterpret_cast(data), size); + std::istringstream stream(bytes, std::ios::in | std::ios::binary); + + try { + static_cast(parser.list_files(stream)); + } catch (const std::invalid_argument &) { + // Invalid numeric fields are expected. + return 0; + } catch (const std::out_of_range &) { + // Invalid numeric fields are expected. std::length_error is deliberately not caught. + return 0; + } catch (const std::runtime_error &) { + // Malformed/truncated archive input is expected. Allocation failures must still escape. + return 0; + } + + return 0; +} diff --git a/src/fuzz/corpus/pickle/external-catch-canvas-index.hex b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex new file mode 100644 index 0000000..b66d546 --- /dev/null +++ b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex @@ -0,0 +1 @@ +80027d710128581a000000696d616765732f6367732f657374656c6c655f7365782e6a70675d71028a041bba62424aad6c5242550087710361581e000000696d616765732f6367732f657374656c6c65626174685f7365782e6a70675d71048a04594d51424a6fab4f425500877105615816000000696d616765732f6367732f6e616f5f7365782e6a706771065d71078a041b6573424a481f49425500877108615817000000696d616765732f6367732f6461776e5f7365782e6a70675d71098a04714242424a954c5142550087710a61752e diff --git a/src/fuzz/corpus/pickle/invalid-empty-int.pickle b/src/fuzz/corpus/pickle/invalid-empty-int.pickle new file mode 100644 index 0000000..db1a5a0 --- /dev/null +++ b/src/fuzz/corpus/pickle/invalid-empty-int.pickle @@ -0,0 +1 @@ +I diff --git a/src/fuzz/corpus/pickle/invalid-mark-position.hex b/src/fuzz/corpus/pickle/invalid-mark-position.hex new file mode 100644 index 0000000..437c486 --- /dev/null +++ b/src/fuzz/corpus/pickle/invalid-mark-position.hex @@ -0,0 +1 @@ +5d4e28616c diff --git a/src/fuzz/corpus/pickle/none.pickle b/src/fuzz/corpus/pickle/none.pickle new file mode 100644 index 0000000..f0e2152 --- /dev/null +++ b/src/fuzz/corpus/pickle/none.pickle @@ -0,0 +1 @@ +N. diff --git a/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex new file mode 100644 index 0000000..16e68d1 --- /dev/null +++ b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex @@ -0,0 +1 @@ +5250412d332e3020303030303030303030303030303134302034323432343234320a000000000000000000000000000000000000000000000000000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff78da6b60aa2d64d48810636060c8cc4d4c4f2dd64f4e2fd6cf4bcc8f2f4eadd0cb2a488f2d64ea6261727272f2727276720a65682f644e2cd50300c4ce1042 diff --git a/src/fuzz/corpus/renpy/valid-rpa2.hex b/src/fuzz/corpus/renpy/valid-rpa2.hex new file mode 100644 index 0000000..5daa12f --- /dev/null +++ b/src/fuzz/corpus/renpy/valid-rpa2.hex @@ -0,0 +1 @@ +5250412d322e3020303030303030303030303030303032310a00000000000000627801ab0d654c8cf556f0666c4b2cd603001aca03d1 diff --git a/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex new file mode 100644 index 0000000..5deed84 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex @@ -0,0 +1 @@ +52 47 53 53 41 44 00 03 01 00 00 00 0d 00 00 00 0d 00 00 00 0d 00 00 00 f3 ff ff ff diff --git a/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex new file mode 100644 index 0000000..f7833e9 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex @@ -0,0 +1 @@ +52475353414400032b440000b9650200a66502009915020099650200c1176370ee0c6173da266a61f4046174e317715ca23c676cea0a752ed62b4586650200000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f diff --git a/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex new file mode 100644 index 0000000..1a9f067 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex @@ -0,0 +1 @@ +5247535341440003010000002d0000000d000000745634120d0000006d0c0000001a diff --git a/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex new file mode 100644 index 0000000..290172c --- /dev/null +++ b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex @@ -0,0 +1 @@ +00 00 00 00 ff ff ff 7f diff --git a/src/fuzz/corpus/zanzarah/declared-path-overflow.hex b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex new file mode 100644 index 0000000..147edbb --- /dev/null +++ b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex @@ -0,0 +1 @@ +00 00 00 00 01 00 00 00 ff ff ff 7f diff --git a/src/fuzz/corpus/zanzarah/external-smallest-entry.hex b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex new file mode 100644 index 0000000..b6c8d4b --- /dev/null +++ b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex @@ -0,0 +1 @@ +0000000001000000280000002e2e5c5245534f55524345535c54455854555245535c4d4f44454c535c48474c303054482e424d50000000001400000000000000000102030405060708090a0b0c0d0e0f diff --git a/src/fuzz/corpus/zanzarah/valid-pak.hex b/src/fuzz/corpus/zanzarah/valid-pak.hex new file mode 100644 index 0000000..99bd93c --- /dev/null +++ b/src/fuzz/corpus/zanzarah/valid-pak.hex @@ -0,0 +1 @@ +0000000001000000010000006100000000050000007856341262 diff --git a/src/fuzz/pickle.cpp b/src/fuzz/pickle.cpp new file mode 100644 index 0000000..4500b4b --- /dev/null +++ b/src/fuzz/pickle.cpp @@ -0,0 +1,29 @@ +#include "../modules/renpy/pickle.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size) +{ + const auto bytes = std::span{ + reinterpret_cast(data), + size, + }; + + try { + static_cast(pickle::loads(bytes)); + } catch (const std::invalid_argument &) { + // Invalid numeric Pickle input is expected. + return 0; + } catch (const std::out_of_range &) { + // Invalid numeric Pickle input is expected. std::length_error is deliberately not caught. + return 0; + } catch (const std::runtime_error &) { + // Invalid Pickle structure is expected. Allocation failures must still escape. + return 0; + } + + return 0; +} diff --git a/src/modules/extractor.h b/src/modules/extractor.h index 5b5e566..fa15456 100644 --- a/src/modules/extractor.h +++ b/src/modules/extractor.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -27,8 +28,8 @@ namespace extractor class read_error final : public std::runtime_error { - public: - read_error(): runtime_error("") + public: + read_error() : runtime_error("") { } }; @@ -44,23 +45,23 @@ namespace extractor { std::string path; std::string header; - int64_t offset; - int64_t compressed_body_size_in_bytes; - int64_t uncompressed_body_size_in_bytes; + int64_t offset = 0; + int64_t compressed_body_size_in_bytes = 0; + int64_t uncompressed_body_size_in_bytes = 0; uint32_t magic = 0; }; class extractor { - public: + public: virtual ~extractor() = default; - static std::vector get_signature() noexcept; + static std::vector get_signature(); - archive_info get_archive_info(const std::span &data) noexcept; + archive_info get_archive_info(const std::span &data); - std::vector > list_files(std::ifstream &stream); + std::vector> list_files(std::istream &stream); uint32_t decrypt(uint32_t magic, std::vector &data) const; }; -} +} // namespace extractor diff --git a/src/modules/renpy/pickle.cpp b/src/modules/renpy/pickle.cpp index f3893bf..3ee65b0 100644 --- a/src/modules/renpy/pickle.cpp +++ b/src/modules/renpy/pickle.cpp @@ -1,43 +1,80 @@ #include "pickle.h" +#include +#include +#include +#include + namespace pickle { + namespace + { + list clone_list(const list &source) + { + list result; + result.reserve(source.size()); + std::ranges::transform(source, std::back_inserter(result), [](const auto &item) { return clone(*item); }); + return result; + } + + dict clone_dict(const dict &source) + { + dict result; + result.reserve(source.size()); + for (const auto &[key, item] : source) { + result.emplace(key, clone(*item)); + } + return result; + } + } // namespace + + value_ptr clone(const value &source) + { + switch (source.get_type()) { + case value::type::none: + return value::none(); + case value::type::bool_: + return value::boolean(source.as_bool()); + case value::type::int64: + return value::int64(source.as_int64()); + case value::type::float64: + return value::float64(source.as_float64()); + case value::type::bytes: + return value::bytes(source.as_string()); + case value::type::string: + return value::string(source.as_string()); + case value::type::list: + return value::list(clone_list(source.as_list())); + case value::type::dict: + return value::dict(clone_dict(source.as_dict())); + case value::type::tuple: + return value::tuple(clone_list(source.as_tuple())); + default: + throw std::runtime_error("Unsupported pickle value type"); + } + } + // Pickle opcodes (subset needed for basic functionality) namespace opcodes { constexpr uint8_t MARK = '('; constexpr uint8_t STOP = '.'; - constexpr uint8_t POP = '0'; - constexpr uint8_t POP_MARK = '1'; - constexpr uint8_t DUP = '2'; - constexpr uint8_t FLOAT = 'F'; constexpr uint8_t INT = 'I'; constexpr uint8_t BININT = 'J'; constexpr uint8_t BININT1 = 'K'; constexpr uint8_t BININT2 = 'M'; constexpr uint8_t NONE = 'N'; - constexpr uint8_t PERSID = 'P'; - constexpr uint8_t BINPERSID = 'Q'; - constexpr uint8_t REDUCE = 'R'; - constexpr uint8_t STRING = 'S'; constexpr uint8_t BINSTRING = 'T'; constexpr uint8_t SHORT_BINSTRING = 'U'; - constexpr uint8_t UNICODE = 'V'; constexpr uint8_t BINUNICODE = 'X'; constexpr uint8_t APPEND = 'a'; - constexpr uint8_t BUILD = 'b'; - constexpr uint8_t GLOBAL = 'c'; constexpr uint8_t DICT = 'd'; constexpr uint8_t EMPTY_DICT = '}'; constexpr uint8_t APPENDS = 'e'; - constexpr uint8_t GET = 'g'; constexpr uint8_t BINGET = 'h'; - constexpr uint8_t INST = 'i'; constexpr uint8_t LONG_BINGET = 'j'; constexpr uint8_t LIST = 'l'; constexpr uint8_t EMPTY_LIST = ']'; - constexpr uint8_t OBJ = 'o'; - constexpr uint8_t PUT = 'p'; constexpr uint8_t BINPUT = 'q'; constexpr uint8_t LONG_BINPUT = 'r'; constexpr uint8_t SETITEM = 's'; @@ -48,17 +85,12 @@ namespace pickle // Protocol 2 constexpr uint8_t PROTO = 0x80; - constexpr uint8_t NEWOBJ = 0x81; - constexpr uint8_t EXT1 = 0x82; - constexpr uint8_t EXT2 = 0x83; - constexpr uint8_t EXT4 = 0x84; constexpr uint8_t TUPLE1 = 0x85; constexpr uint8_t TUPLE2 = 0x86; constexpr uint8_t TUPLE3 = 0x87; constexpr uint8_t NEWTRUE = 0x88; constexpr uint8_t NEWFALSE = 0x89; constexpr uint8_t LONG1 = 0x8a; - constexpr uint8_t LONG4 = 0x8b; // Protocol 3 constexpr uint8_t BINBYTES = 'B'; @@ -66,16 +98,9 @@ namespace pickle // Protocol 4 constexpr uint8_t SHORT_BINUNICODE = 0x8c; - constexpr uint8_t BINUNICODE8 = 0x8d; - constexpr uint8_t BINBYTES8 = 0x8e; - constexpr uint8_t EMPTY_SET = 0x8f; - constexpr uint8_t ADDITEMS = 0x90; - constexpr uint8_t FROZENSET = 0x91; - constexpr uint8_t NEWOBJ_EX = 0x92; - constexpr uint8_t STACK_GLOBAL = 0x93; constexpr uint8_t MEMOIZE = 0x94; constexpr uint8_t FRAME = 0x95; - } + } // namespace opcodes uint8_t parser::read_byte() { @@ -90,8 +115,7 @@ namespace pickle if (pos_ + 2 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint16_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8; + const uint16_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8; pos_ += 2; return result; } @@ -101,8 +125,7 @@ namespace pickle if (pos_ + 4 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint32_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8 | + const uint32_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 | static_cast(data_[pos_ + 2]) << 16 | static_cast(data_[pos_ + 3]) << 24; pos_ += 4; @@ -114,14 +137,11 @@ namespace pickle if (pos_ + 8 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint64_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8 | - static_cast(data_[pos_ + 2]) << 16 | - static_cast(data_[pos_ + 3]) << 24 | - static_cast(data_[pos_ + 4]) << 32 | - static_cast(data_[pos_ + 5]) << 40 | - static_cast(data_[pos_ + 6]) << 48 | - static_cast(data_[pos_ + 7]) << 56; + const uint64_t result = + static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 | + static_cast(data_[pos_ + 2]) << 16 | static_cast(data_[pos_ + 3]) << 24 | + static_cast(data_[pos_ + 4]) << 32 | static_cast(data_[pos_ + 5]) << 40 | + static_cast(data_[pos_ + 6]) << 48 | static_cast(data_[pos_ + 7]) << 56; pos_ += 8; return result; } @@ -161,6 +181,9 @@ namespace pickle throw std::runtime_error("No mark on stack"); } const size_t mark_pos = mark_stack_.back(); + if (mark_pos > stack_.size()) { + throw std::runtime_error("Invalid mark position"); + } mark_stack_.pop_back(); list result; @@ -175,375 +198,344 @@ namespace pickle value_ptr parser::parse_value() { switch (uint8_t opcode = read_byte()) { - case opcodes::MARK: - push_mark(); - break; + case opcodes::MARK: + push_mark(); + break; - case opcodes::STOP: - if (stack_.size() != 1) { - throw std::runtime_error("Invalid stack size at end of pickle"); - } - return std::move(stack_[0]); + case opcodes::STOP: + if (stack_.size() != 1) { + throw std::runtime_error("Invalid stack size at end of pickle"); + } + return std::move(stack_[0]); - case opcodes::NONE: - stack_.push_back(value::none()); - break; + case opcodes::NONE: + stack_.push_back(value::none()); + break; - case opcodes::NEWTRUE: - stack_.push_back(value::boolean(true)); - break; + case opcodes::NEWTRUE: + stack_.push_back(value::boolean(true)); + break; - case opcodes::NEWFALSE: - stack_.push_back(value::boolean(false)); - break; + case opcodes::NEWFALSE: + stack_.push_back(value::boolean(false)); + break; - case opcodes::BININT: - stack_.push_back(value::int64(static_cast(read_uint32_le()))); - break; + case opcodes::BININT: + stack_.push_back(value::int64(static_cast(read_uint32_le()))); + break; - case opcodes::BININT1: - stack_.push_back(value::int64(read_byte())); - break; + case opcodes::BININT1: + stack_.push_back(value::int64(read_byte())); + break; - case opcodes::BININT2: - stack_.push_back(value::int64(read_uint16_le())); - break; + case opcodes::BININT2: + stack_.push_back(value::int64(read_uint16_le())); + break; - case opcodes::INT: - { - std::string int_str = read_line(); - if (int_str.back() == 'L') { - int_str.pop_back(); // Remove trailing L - } - int64_t val = std::stoll(int_str); - stack_.push_back(value::int64(val)); - break; + case opcodes::INT: { + std::string int_str = read_line(); + if (int_str.empty()) { + throw std::runtime_error("Empty INT opcode argument"); } - - case opcodes::BINFLOAT: - { - uint64_t bits = read_uint64_le(); - double val; - std::memcpy(&val, &bits, sizeof(double)); - stack_.push_back(value::float64(val)); - break; + if (int_str.back() == 'L') { + int_str.pop_back(); // Remove trailing L } + int64_t val = std::stoll(int_str); + stack_.push_back(value::int64(val)); + break; + } - case opcodes::SHORT_BINSTRING: - { - uint8_t length = read_byte(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::BINFLOAT: { + const uint64_t bits = std::byteswap(read_uint64_le()); + double val; + std::memcpy(&val, &bits, sizeof(double)); + stack_.push_back(value::float64(val)); + break; + } - case opcodes::BINSTRING: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::SHORT_BINSTRING: { + uint8_t length = read_byte(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::SHORT_BINUNICODE: - { - uint8_t length = read_byte(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::BINSTRING: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::BINUNICODE: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::SHORT_BINUNICODE: { + uint8_t length = read_byte(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::SHORT_BINBYTES: - { - uint8_t length = read_byte(); - stack_.push_back(value::bytes(read_string(length))); - break; - } + case opcodes::BINUNICODE: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::BINBYTES: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::bytes(read_string(length))); - break; - } + case opcodes::SHORT_BINBYTES: { + uint8_t length = read_byte(); + stack_.push_back(value::bytes(read_string(length))); + break; + } - case opcodes::EMPTY_LIST: - stack_.push_back(value::list({})); - break; + case opcodes::BINBYTES: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::bytes(read_string(length))); + break; + } - case opcodes::APPEND: - { - if (stack_.size() < 2) { - throw std::runtime_error("Not enough items on stack for APPEND"); - } - auto item = std::move(stack_.back()); - stack_.pop_back(); - auto &list_val = stack_.back(); - if (list_val->get_type() != value::type::list) { - throw std::runtime_error("APPEND target is not a list"); - } - auto &list_data = const_cast(list_val->as_list()); - list_data.push_back(std::move(item)); - break; - } + case opcodes::EMPTY_LIST: + stack_.push_back(value::list({})); + break; - case opcodes::APPENDS: - { - auto items = pop_to_mark(); - if (stack_.empty()) { - throw std::runtime_error("No list on stack for APPENDS"); - } - auto &list_val = stack_.back(); - if (list_val->get_type() != value::type::list) { - throw std::runtime_error("APPENDS target is not a list"); - } - auto &list_data = const_cast(list_val->as_list()); - for (auto &item: items) { - list_data.push_back(std::move(item)); - } - break; + case opcodes::APPEND: { + if (stack_.size() < 2) { + throw std::runtime_error("Not enough items on stack for APPEND"); + } + auto item = std::move(stack_.back()); + stack_.pop_back(); + const auto &list_val = stack_.back(); + if (list_val->get_type() != value::type::list) { + throw std::runtime_error("APPEND target is not a list"); } + auto &list_data = const_cast(list_val->as_list()); + list_data.push_back(std::move(item)); + break; + } - case opcodes::LIST: - { - auto items = pop_to_mark(); - stack_.push_back(value::list(std::move(items))); - break; + case opcodes::APPENDS: { + auto items = pop_to_mark(); + if (stack_.empty()) { + throw std::runtime_error("No list on stack for APPENDS"); } + const auto &list_val = stack_.back(); + if (list_val->get_type() != value::type::list) { + throw std::runtime_error("APPENDS target is not a list"); + } + auto &list_data = const_cast(list_val->as_list()); + std::ranges::move(items, std::back_inserter(list_data)); + break; + } - case opcodes::EMPTY_TUPLE: - stack_.push_back(value::tuple({})); - break; + case opcodes::LIST: { + auto items = pop_to_mark(); + stack_.push_back(value::list(std::move(items))); + break; + } - case opcodes::TUPLE: - { - auto items = pop_to_mark(); - stack_.push_back(value::tuple(std::move(items))); - break; - } + case opcodes::EMPTY_TUPLE: + stack_.push_back(value::tuple({})); + break; - case opcodes::TUPLE1: - { - if (stack_.empty()) { - throw std::runtime_error("Not enough items on stack for TUPLE1"); - } - auto item = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE: { + auto items = pop_to_mark(); + stack_.push_back(value::tuple(std::move(items))); + break; + } + + case opcodes::TUPLE1: { + if (stack_.empty()) { + throw std::runtime_error("Not enough items on stack for TUPLE1"); } + auto item = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::TUPLE2: - { - if (stack_.size() < 2) { - throw std::runtime_error("Not enough items on stack for TUPLE2"); - } - auto item2 = std::move(stack_.back()); - stack_.pop_back(); - auto item1 = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item1)); - tuple_items.push_back(std::move(item2)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE2: { + if (stack_.size() < 2) { + throw std::runtime_error("Not enough items on stack for TUPLE2"); } + auto item2 = std::move(stack_.back()); + stack_.pop_back(); + auto item1 = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item1)); + tuple_items.push_back(std::move(item2)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::TUPLE3: - { - if (stack_.size() < 3) { - throw std::runtime_error("Not enough items on stack for TUPLE3"); - } - auto item3 = std::move(stack_.back()); - stack_.pop_back(); - auto item2 = std::move(stack_.back()); - stack_.pop_back(); - auto item1 = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item1)); - tuple_items.push_back(std::move(item2)); - tuple_items.push_back(std::move(item3)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE3: { + if (stack_.size() < 3) { + throw std::runtime_error("Not enough items on stack for TUPLE3"); } + auto item3 = std::move(stack_.back()); + stack_.pop_back(); + auto item2 = std::move(stack_.back()); + stack_.pop_back(); + auto item1 = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item1)); + tuple_items.push_back(std::move(item2)); + tuple_items.push_back(std::move(item3)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::EMPTY_DICT: - stack_.push_back(value::dict({})); - break; + case opcodes::EMPTY_DICT: + stack_.push_back(value::dict({})); + break; - case opcodes::DICT: - { - auto items = pop_to_mark(); - if (items.size() % 2 != 0) { - throw std::runtime_error("Odd number of items for DICT"); - } - dict dict_data; - for (size_t i = 0; i < items.size(); i += 2) { - if (items[i]->get_type() != value::type::string) { - throw std::runtime_error("Dict key must be string"); - } - std::string key = items[i]->as_string(); - dict_data[std::move(key)] = std::move(items[i + 1]); - } - stack_.push_back(value::dict(std::move(dict_data))); - break; + case opcodes::DICT: { + auto items = pop_to_mark(); + if (items.size() % 2 != 0) { + throw std::runtime_error("Odd number of items for DICT"); } - - case opcodes::SETITEM: - { - if (stack_.size() < 3) { - throw std::runtime_error("Not enough items on stack for SETITEM"); - } - auto val = std::move(stack_.back()); - stack_.pop_back(); - auto key = std::move(stack_.back()); - stack_.pop_back(); - auto &dict_val = stack_.back(); - - if (dict_val->get_type() != value::type::dict) { - throw std::runtime_error("SETITEM target is not a dict"); - } - if (key->get_type() != value::type::string) { + dict dict_data; + for (size_t i = 0; i < items.size(); i += 2) { + if (items[i]->get_type() != value::type::string) { throw std::runtime_error("Dict key must be string"); } + std::string key = items[i]->as_string(); + dict_data[std::move(key)] = std::move(items[i + 1]); + } + stack_.push_back(value::dict(std::move(dict_data))); + break; + } - auto &dict_data = const_cast(dict_val->as_dict()); - dict_data[key->as_string()] = std::move(val); - break; + case opcodes::SETITEM: { + if (stack_.size() < 3) { + throw std::runtime_error("Not enough items on stack for SETITEM"); + } + auto val = std::move(stack_.back()); + stack_.pop_back(); + auto key = std::move(stack_.back()); + stack_.pop_back(); + const auto &dict_val = stack_.back(); + + if (dict_val->get_type() != value::type::dict) { + throw std::runtime_error("SETITEM target is not a dict"); + } + if (key->get_type() != value::type::string) { + throw std::runtime_error("Dict key must be string"); } - case opcodes::SETITEMS: - { - auto items = pop_to_mark(); - if (items.size() % 2 != 0) { - throw std::runtime_error("Odd number of items for SETITEMS"); - } - if (stack_.empty()) { - throw std::runtime_error("No dict on stack for SETITEMS"); - } - auto &dict_val = stack_.back(); - if (dict_val->get_type() != value::type::dict) { - throw std::runtime_error("SETITEMS target is not a dict"); - } + auto &dict_data = const_cast(dict_val->as_dict()); + dict_data[key->as_string()] = std::move(val); + break; + } - auto &dict_data = const_cast(dict_val->as_dict()); - for (size_t i = 0; i < items.size(); i += 2) { - if (items[i]->get_type() != value::type::string) { - throw std::runtime_error("Dict key must be string"); - } - std::string key = items[i]->as_string(); - dict_data[std::move(key)] = std::move(items[i + 1]); - } - break; + case opcodes::SETITEMS: { + auto items = pop_to_mark(); + if (items.size() % 2 != 0) { + throw std::runtime_error("Odd number of items for SETITEMS"); } - - case opcodes::BINPUT: - { - uint8_t memo_id = read_byte(); - if (stack_.empty()) { - throw std::runtime_error("No item on stack for BINPUT"); - } - // For simplicity, we create a copy for memo storage - // In a full implementation, you'd want to share the object - memo_[memo_id] = nullptr; // Placeholder for now - break; + if (stack_.empty()) { + throw std::runtime_error("No dict on stack for SETITEMS"); + } + const auto &dict_val = stack_.back(); + if (dict_val->get_type() != value::type::dict) { + throw std::runtime_error("SETITEMS target is not a dict"); } - case opcodes::LONG_BINPUT: - { - uint32_t memo_id = read_uint32_le(); - if (stack_.empty()) { - throw std::runtime_error("No item on stack for LONG_BINPUT"); + auto &dict_data = const_cast(dict_val->as_dict()); + for (size_t i = 0; i < items.size(); i += 2) { + if (items[i]->get_type() != value::type::string) { + throw std::runtime_error("Dict key must be string"); } - // For simplicity, we create a copy for memo storage - // In a full implementation, you'd want to share the object - memo_[memo_id] = nullptr; // Placeholder for now - break; + std::string key = items[i]->as_string(); + dict_data[std::move(key)] = std::move(items[i + 1]); } + break; + } - case opcodes::BINGET: - { - uint8_t memo_id = read_byte(); - if (auto it = memo_.find(memo_id); it == memo_.end()) { - throw std::runtime_error("Memo key not found"); - } - // For now, just push a placeholder - stack_.push_back(value::none()); - break; + case opcodes::BINPUT: { + uint8_t memo_id = read_byte(); + if (stack_.empty()) { + throw std::runtime_error("No item on stack for BINPUT"); } + memo_[memo_id] = clone(*stack_.back()); + break; + } - case opcodes::LONG_BINGET: - { - uint32_t memo_id = read_uint32_le(); - if (auto it = memo_.find(memo_id); it == memo_.end()) { - throw std::runtime_error("Memo key not found"); - } - // For now, just push a placeholder - stack_.push_back(value::none()); - break; + case opcodes::LONG_BINPUT: { + uint32_t memo_id = read_uint32_le(); + if (stack_.empty()) { + throw std::runtime_error("No item on stack for LONG_BINPUT"); } + memo_[memo_id] = clone(*stack_.back()); + break; + } - case opcodes::PROTO: - { - uint8_t proto = read_byte(); - // Just ignore protocol version for now - break; + case opcodes::BINGET: { + uint8_t memo_id = read_byte(); + const auto it = memo_.find(memo_id); + if (it == memo_.end()) { + throw std::runtime_error("Memo key not found"); } + stack_.push_back(clone(*it->second)); + break; + } - case opcodes::FRAME: - { - uint64_t frame_size = read_uint64_le(); - // Just ignore frame size for now - break; + case opcodes::LONG_BINGET: { + uint32_t memo_id = read_uint32_le(); + const auto it = memo_.find(memo_id); + if (it == memo_.end()) { + throw std::runtime_error("Memo key not found"); } + stack_.push_back(clone(*it->second)); + break; + } - case opcodes::LONG1: - { - uint8_t length = read_byte(); - if (length == 0) { - stack_.push_back(value::int64(0)); - } else { - std::string bytes_data = read_string(length); - int64_t result = 0; - - // Convert little-endian bytes to integer - for (int i = length - 1; i >= 0; --i) { - result = result << 8 | static_cast(bytes_data[i]); - } - - // Handle two's complement for negative numbers - if (length > 0 && static_cast(bytes_data[length - 1]) & 0x80) { - // Extend sign bit - for (int i = length; i < 8; ++i) { - result |= 0xFFLL << i * 8; - } - } - - stack_.push_back(value::int64(result)); + case opcodes::PROTO: { + read_byte(); + // Just ignore protocol version for now + break; + } + + case opcodes::FRAME: { + read_uint64_le(); + // Just ignore frame size for now + break; + } + + case opcodes::LONG1: { + uint8_t length = read_byte(); + if (length == 0) { + stack_.push_back(value::int64(0)); + } else { + if (length > sizeof(std::int64_t)) { + throw std::runtime_error("LONG1 value does not fit in int64"); } - break; - } - case opcodes::MEMOIZE: - { - if (stack_.empty()) { - throw std::runtime_error("No item on stack for MEMOIZE"); + const std::string bytes_data = read_string(length); + auto bits = std::accumulate(bytes_data.rbegin(), bytes_data.rend(), std::uint64_t{0}, + [](const std::uint64_t current, const char byte) { + return current << 8 | static_cast(byte); + }); + + // Handle two's complement for negative numbers + if (length < sizeof(bits) && (static_cast(bytes_data.back()) & 0x80) != 0) { + bits |= ~std::uint64_t{0} << length * 8; } - // Store the top item in memo with auto-incrementing ID - auto memo_id = static_cast(memo_.size()); - memo_[memo_id] = nullptr; // Placeholder for now - break; + + stack_.push_back(value::int64(std::bit_cast(bits))); } + break; + } + + case opcodes::MEMOIZE: { + if (stack_.empty()) { + throw std::runtime_error("No item on stack for MEMOIZE"); + } + const auto memo_id = static_cast(memo_.size()); + memo_[memo_id] = clone(*stack_.back()); + break; + } - default: - throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode)); + default: + throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode)); } return nullptr; // Continue parsing @@ -570,4 +562,4 @@ namespace pickle const auto byte_span = std::span(reinterpret_cast(data.data()), data.size()); return loads(byte_span); } -} +} // namespace pickle diff --git a/src/modules/renpy/pickle.h b/src/modules/renpy/pickle.h index eacb388..cfae788 100644 --- a/src/modules/renpy/pickle.h +++ b/src/modules/renpy/pickle.h @@ -1,12 +1,13 @@ #pragma once -#include -#include -#include -#include +#include #include #include #include +#include +#include +#include +#include namespace pickle { @@ -18,8 +19,8 @@ namespace pickle class value { - public: - enum class type + public: + enum class type : std::uint8_t { none, bool_, @@ -32,19 +33,19 @@ namespace pickle tuple }; - private: + private: type type_; - std::variant< - std::monostate, // none - bool, // bool_ - int64_t, // int64 - double, // float64 - std::string, // bytes/string - list, // list/tuple - dict // dict - > data_; - - public: + std::variant + data_; + + public: explicit value(const type t) : type_(t) { } @@ -110,7 +111,10 @@ namespace pickle return v; } - type get_type() const { return type_; } + type get_type() const + { + return type_; + } bool as_bool() const { @@ -169,9 +173,11 @@ namespace pickle } }; + value_ptr clone(const value &source); + class parser { - private: + private: std::span data_; size_t pos_ = 0; std::vector stack_; @@ -196,7 +202,7 @@ namespace pickle value_ptr parse_value(); - public: + public: explicit parser(const std::span data) : data_(data) { } @@ -207,4 +213,4 @@ namespace pickle value_ptr loads(std::span data); value_ptr loads(const std::string &data); -} +} // namespace pickle diff --git a/src/modules/renpy/renpy.cpp b/src/modules/renpy/renpy.cpp index 63b047b..9692565 100644 --- a/src/modules/renpy/renpy.cpp +++ b/src/modules/renpy/renpy.cpp @@ -1,10 +1,11 @@ +#include "../../core/compression/zlib_codec.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include "pickle.h" #include #include - -#include +#include namespace extractor { @@ -12,11 +13,12 @@ namespace extractor { return { {0x9486718f, 0x8f0a, 0x4de7, {0x98, 0x80, 0x01, 0x14, 0x6b, 0x33, 0x6d, 0x6b}}, - 3, 0, + 3, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { const std::string str = "RPA-"; std::vector signature(str.size()); @@ -24,18 +26,20 @@ namespace extractor return signature; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"RenPy", L"", L""}; } - int64_t read_int64(std::ifstream &stream) + int64_t read_int64(observer::io::bounded_stream &stream) { std::string buffer(sizeof(int64_t) * 2, '\0'); - stream.read(buffer.data(), std::ssize(buffer)); + stream.read_exact(buffer.data(), buffer.size()); char *end_ptr = nullptr; + errno = 0; const int64_t result = std::strtoll(buffer.c_str(), &end_ptr, 16); if (errno == ERANGE || result == 0 || result < 0) { throw std::out_of_range("NumberReadNotANumberError"); @@ -44,68 +48,68 @@ namespace extractor return result; } - std::pair(int64_t, int64_t)> > parse_header( - std::ifstream &stream) + std::pair(int64_t, int64_t)>> parse_header( + observer::io::bounded_stream &stream) { std::string version_check(3, '\0'); - stream.seekg(static_cast(extractor::get_signature().size())); - stream.read(version_check.data(), 3); + stream.seek_absolute(static_cast(extractor::get_signature().size())); + stream.read_exact(version_check.data(), version_check.size()); if (version_check == "2.0") { - stream.seekg(static_cast(std::string("RPA-2.0 ").length())); + stream.seek_absolute(static_cast(std::string("RPA-2.0 ").length())); const auto index_offset = read_int64(stream); - return { - index_offset, [](int64_t offset, int64_t length) - { - return std::make_pair(offset, length); - } - }; + return {index_offset, [](int64_t offset, int64_t length) { return std::make_pair(offset, length); }}; } if (version_check == "3.0") { - stream.seekg(static_cast(std::string("RPA-3.0 ").length())); + stream.seek_absolute(static_cast(std::string("RPA-3.0 ").length())); const auto index_offset = read_int64(stream); const auto encryption_key = read_int64(stream); - return { - index_offset, [encryption_key](int64_t offset, int64_t length) - { - return std::make_pair(offset ^ encryption_key, length ^ encryption_key); - } - }; + return {index_offset, [encryption_key](int64_t offset, int64_t length) { + return std::make_pair(offset ^ encryption_key, length ^ encryption_key); + }}; } throw std::runtime_error("Unsupported RPA version"); } - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - const auto [index_offset, decoder] = parse_header(stream); - - stream.seekg(index_offset); - - std::string decompressed_data; - try { - zstr::istream zs(stream); - decompressed_data.assign(std::istreambuf_iterator(zs), std::istreambuf_iterator()); - } catch (const std::ios_base::failure &) { + observer::io::bounded_stream input(stream); + const auto [index_offset, decoder] = parse_header(input); + const auto archive_size = input.size(); + if (index_offset >= archive_size) { throw read_error(); } + input.seek_absolute(index_offset); + + constexpr std::size_t max_compressed_index_size = 64ULL * 1024 * 1024; + constexpr std::size_t max_decompressed_index_size = 64ULL * 1024 * 1024; + const auto compressed_size = archive_size - index_offset; + if (static_cast(compressed_size) > max_compressed_index_size) { + throw std::runtime_error("RPA compressed index exceeds the metadata budget"); + } + + std::vector compressed_data(static_cast(compressed_size)); + input.read_exact(reinterpret_cast(compressed_data.data()), compressed_data.size()); + const auto decompressed_data = + observer::compression::decompress_zlib(compressed_data, max_decompressed_index_size); auto root = pickle::loads(decompressed_data); const auto &dict = root->as_dict(); - auto files = std::vector >(); + auto files = std::vector>(); files.reserve(dict.size()); - for (const auto &[file_name, value]: dict) { + for (const auto &[file_name, value] : dict) { const auto &props_container = value->as_list(); if (props_container.size() != 1) { - throw std::logic_error("Not implemented"); + throw std::runtime_error("Expected exactly one property tuple"); } const auto &props = props_container[0]->as_tuple(); if (props.size() < 2) { - throw std::logic_error("Expected at least 2 elements in tuple"); + throw std::runtime_error("Expected at least 2 elements in tuple"); } const auto [offset, body_size] = decoder(props[0]->as_int64(), props[1]->as_int64()); @@ -130,6 +134,7 @@ namespace extractor uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const { + static_cast(data); return magic; } -} +} // namespace extractor diff --git a/src/modules/rpgmaker/rpgmaker.cpp b/src/modules/rpgmaker/rpgmaker.cpp index af6baf0..6a0fa04 100644 --- a/src/modules/rpgmaker/rpgmaker.cpp +++ b/src/modules/rpgmaker/rpgmaker.cpp @@ -1,3 +1,5 @@ +#include "../../core/archive_limits.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include @@ -8,11 +10,12 @@ namespace extractor { return { {0xc4674077, 0x464a, 0x425b, {0x89, 0x80, 0x9e, 0x14, 0xe8, 0x16, 0x49, 0x00}}, - 1, 0, + 1, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { const std::string str = "RGSSAD"; std::vector signature(str.size()); @@ -22,40 +25,42 @@ namespace extractor return signature; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"RGSS3", L"-", L"RPG Maker VX Ace"}; } - static uint32_t read_u32(std::ifstream &stream) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - uint32_t value; - stream.read(reinterpret_cast(&value), sizeof(value)); - return value; - } - - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) - { - stream.seekg(static_cast(get_signature().size())); + observer::io::bounded_stream input(stream); + input.seek_absolute(static_cast(get_signature().size())); + const auto archive_size = input.size(); - std::vector > files; - const uint32_t magic = read_u32(stream) * 9 + 3; + std::vector> files; + const uint32_t magic = input.read_trivial() * 9 + 3; while (true) { - const uint32_t offset = read_u32(stream) ^ magic; - if (offset == 0) break; + const uint32_t offset = input.read_trivial() ^ magic; + if (offset == 0) + break; - const uint32_t size = read_u32(stream) ^ magic; - const uint32_t file_magic = read_u32(stream) ^ magic; - const uint32_t name_len = read_u32(stream) ^ magic; + const uint32_t size = input.read_trivial() ^ magic; + const uint32_t file_magic = input.read_trivial() ^ magic; + const uint32_t name_len = input.read_trivial() ^ magic; + + const auto name_position = input.position(); + if (name_len > observer::archive_limits::max_path_bytes || + static_cast(name_len) > static_cast(archive_size - name_position)) { + throw read_error(); + } std::vector name_buf(name_len); - stream.read(name_buf.data(), name_len); + input.read_exact(name_buf.data(), name_buf.size()); for (size_t i = 0; i < name_len; ++i) { - name_buf[i] = static_cast( - static_cast(name_buf[i]) ^ - static_cast(magic >> (8 * (i % 4)))); + name_buf[i] = static_cast(static_cast(name_buf[i]) ^ + static_cast(magic >> (8 * (i % 4)))); } auto new_file = std::make_unique(); @@ -76,8 +81,8 @@ namespace extractor return old; } - // ReSharper disable once CppMemberFunctionMayBeStatic - uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const // NOLINT(*-convert-member-functions-to-static) + uint32_t extractor::decrypt(uint32_t magic, + std::vector &data) const // NOLINT(*-convert-member-functions-to-static) { const size_t size = data.size(); size_t i = 0; @@ -98,4 +103,4 @@ namespace extractor return magic; } -} +} // namespace extractor diff --git a/src/modules/zanzarah/zanzarah.cpp b/src/modules/zanzarah/zanzarah.cpp index e4cb88f..d96a130 100644 --- a/src/modules/zanzarah/zanzarah.cpp +++ b/src/modules/zanzarah/zanzarah.cpp @@ -1,3 +1,5 @@ +#include "../../core/archive_limits.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include @@ -9,30 +11,26 @@ namespace extractor { return { {0x86e7e4c3, 0xbc44, 0x4e8e, {0x90, 0xaf, 0xbd, 0xbd, 0x1c, 0xb6, 0x1a, 0x83}}, - 2, 0, + 2, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { return {std::byte{0}, std::byte{0}, std::byte{0}, std::byte{0}}; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"Zanzarah", L"", L""}; } - int32_t read_positive_or_zero_int32(std::ifstream &stream) + int32_t read_positive_or_zero_int32(observer::io::bounded_stream &stream) { - int32_t value; - - try { - stream.read(reinterpret_cast(&value), sizeof(value)); - } catch (std::ios_base::failure &) { - throw read_error(); - } + const auto value = stream.read_trivial(); if (value < 0) { throw read_error(); @@ -41,7 +39,7 @@ namespace extractor return value; } - int32_t read_positive_int32(std::ifstream &stream) + int32_t read_positive_int32(observer::io::bounded_stream &stream) { const int32_t value = read_positive_or_zero_int32(stream); if (value == 0) { @@ -50,36 +48,57 @@ namespace extractor return value; } - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - stream.seekg(static_cast(get_signature().size())); + observer::io::bounded_stream input(stream); + input.seek_absolute(static_cast(get_signature().size())); + const auto archive_size = input.size(); + const auto file_count = static_cast(read_positive_int32(input)); + constexpr std::size_t minimum_entry_metadata_bytes = sizeof(std::int32_t) * 3 + 1; + const auto remaining_bytes = static_cast(input.remaining()); + if (file_count > observer::archive_limits::max_entry_count || + file_count > remaining_bytes / minimum_entry_metadata_bytes) { + throw read_error(); + } - auto files = std::vector >(); - files.reserve(read_positive_int32(stream)); + auto files = std::vector>(); + files.reserve(file_count); std::string path; - for (size_t i = 0; i < files.capacity(); ++i) { - const auto path_len = read_positive_int32(stream); + for (std::size_t i = 0; i < file_count; ++i) { + const auto path_len = static_cast(read_positive_int32(input)); + if (path_len > observer::archive_limits::max_path_bytes || + path_len > static_cast(input.remaining())) { + throw read_error(); + } path.resize(path_len); - stream.read(path.data(), path_len); + input.read_exact(path.data(), path.size()); - const auto block_offset = read_positive_or_zero_int32(stream); - const auto block_size = read_positive_int32(stream); + const auto block_offset = read_positive_or_zero_int32(input); + const auto block_size = read_positive_int32(input); constexpr int32_t attr_size = 4; + if (block_size < attr_size) { + throw read_error(); + } auto new_file = std::make_unique(); new_file->path = path; - new_file->offset = block_offset + attr_size; - new_file->compressed_body_size_in_bytes = block_size - attr_size; + new_file->offset = static_cast(block_offset) + attr_size; + new_file->compressed_body_size_in_bytes = static_cast(block_size) - attr_size; new_file->uncompressed_body_size_in_bytes = new_file->compressed_body_size_in_bytes; files.push_back(std::move(new_file)); } - for (const auto &file: files) { - file->offset += stream.tellg(); + const auto body_position = input.position(); + const auto body_bytes = static_cast(archive_size - body_position); + for (const auto &file : files) { + if (file->offset > body_bytes || file->compressed_body_size_in_bytes > body_bytes - file->offset) { + throw read_error(); + } + file->offset += body_position; if (constexpr std::string_view relative_prefix = "..\\"; file->path.starts_with(relative_prefix)) { file->path.erase(0, relative_prefix.size()); } @@ -90,6 +109,7 @@ namespace extractor uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const { + static_cast(data); return magic; } -} +} // namespace extractor diff --git a/src/tests/framework/observer.cpp b/src/tests/framework/observer.cpp index 127930a..5c3abcc 100644 --- a/src/tests/framework/observer.cpp +++ b/src/tests/framework/observer.cpp @@ -1,55 +1,102 @@ #include "observer.h" #include "../../api.h" +#include "../support/archive_fixtures.h" +#include +#include +#include #include +#include +#include -#include #include +#include namespace test { + enum class module_path_policy : std::uint8_t + { + loader_search, + exact, + }; + class c_module final : public module { - public: + public: explicit c_module(const std::string &dll_name) + : c_module(std::filesystem::path(dll_name), module_path_policy::loader_search) { - dll_ = LoadLibrary(dll_name.c_str()); - if (dll_ == nullptr) { - throw std::runtime_error("Failed to load DLL"); - } - - const auto load = reinterpret_cast(GetProcAddress(dll_, "LoadSubModule")); - unload_module_ = reinterpret_cast(GetProcAddress(dll_, "UnloadSubModule")); - - ModuleLoadParameters load_params{}; - load_params.StructSize = sizeof(load_params); - load_params.Settings = nullptr; - load(&load_params); - api_ = load_params.ApiFuncs; - module_loaded_ = true; } - ~c_module() override + c_module(const std::filesystem::path &dll_path, const module_path_policy path_policy) { - if (storage_ != nullptr) { - api_.CloseStorage(storage_); + if (path_policy == module_path_policy::exact && !dll_path.is_absolute()) { + throw std::runtime_error("An exact module path must be absolute"); } - if (module_loaded_) { - unload_module_(); - unload_module_ = nullptr; + const auto load_path = + path_policy == module_path_policy::exact ? std::filesystem::canonical(dll_path) : dll_path; + dll_ = path_policy == module_path_policy::exact + ? LoadLibraryExW(load_path.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) + : LoadLibraryW(load_path.c_str()); + if (dll_ == nullptr) { + throw std::runtime_error( + std::format("Failed to load {} (Win32 error {})", load_path.string(), GetLastError())); } - if (dll_ != nullptr) { - FreeLibrary(dll_); + try { + if (path_policy == module_path_policy::exact) { + std::wstring actual_path(32'768, L'\0'); + const auto length = + GetModuleFileNameW(dll_, actual_path.data(), static_cast(actual_path.size())); + if (length == 0 || length >= actual_path.size()) { + throw std::runtime_error("Failed to resolve the loaded package module path"); + } + actual_path.resize(length); + if (!std::filesystem::equivalent(load_path, std::filesystem::canonical(actual_path))) { + throw std::runtime_error("The loader did not map the requested canonical package module"); + } + } + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wcast-function-type-mismatch" +#endif + load_module_ = reinterpret_cast(GetProcAddress(dll_, "LoadSubModule")); + unload_module_ = reinterpret_cast(GetProcAddress(dll_, "UnloadSubModule")); +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + if (load_module_ == nullptr || unload_module_ == nullptr) { + throw std::runtime_error("The package module does not expose the Observer entry points"); + } + + ModuleLoadParameters load_params{}; + load_params.StructSize = sizeof(load_params); + load_params.Settings = nullptr; + if (load_module_(&load_params) == FALSE) { + throw std::runtime_error("LoadSubModule rejected its valid package-smoke parameters"); + } + api_ = load_params.ApiFuncs; + module_loaded_ = true; + } catch (...) { + release(); + throw; } } + ~c_module() override + { + release(); + } + bool open(const std::filesystem::path &path) { + REQUIRE(storage_ == nullptr); std::ifstream input(path, std::ios::binary); REQUIRE(input.is_open()); - input.exceptions(std::ofstream::failbit | std::ofstream::badbit); + input.exceptions(std::ofstream::badbit); auto len = 128 * 1024; std::string signature; @@ -79,6 +126,7 @@ namespace test { REQUIRE(storage_ != nullptr); REQUIRE(api_.PrepareFiles(storage_)); + REQUIRE(api_.PrepareFiles(storage_)); std::vector files{}; @@ -97,12 +145,12 @@ namespace test REQUIRE(item.NumHardlinks == 0); REQUIRE(wcslen(item.Path) > 0); - auto extract = [this, item_index](const std::filesystem::path &path) - { + auto extract = [this, item_index](const std::filesystem::path &path) { extract_file(item_index, path); }; - files.emplace_back(item.Path, item.Size, item.PackedSize, extract); + files.emplace_back(item.Path, static_cast(item.Size), + static_cast(item.PackedSize), extract); ++item_index; } @@ -110,14 +158,32 @@ namespace test return files; } + [[nodiscard]] const module_cbs &api() const noexcept + { + return api_; + } + + [[nodiscard]] HANDLE storage() const noexcept + { + return storage_; + } + + [[nodiscard]] int load(ModuleLoadParameters *params) const + { + return load_module_(params); + } + void extract_file(const int file_index, const std::filesystem::path &path) const { - constexpr ExtractProcessCallbacks callbacks{ + REQUIRE(extract_file_status(file_index, path, [](void *, int64_t) { return TRUE; }) == SER_SUCCESS); + } + + [[nodiscard]] int extract_file_status(const int file_index, const std::filesystem::path &path, + const ExtractProgressFunc report_progress) const + { + const ExtractProcessCallbacks callbacks{ .signalContext = nullptr, - .FileProgress = [](void *context, int64_t bytes_read) - { - return TRUE; - }, + .FileProgress = report_progress, }; const ExtractOperationParams params{ @@ -128,17 +194,68 @@ namespace test .Callbacks = callbacks, }; - REQUIRE(api_.ExtractItem(storage_, params) == SER_SUCCESS); + return api_.ExtractItem(storage_, params); + } + + void close_storage() noexcept + { + if (storage_ != nullptr) { + api_.CloseStorage(storage_); + storage_ = nullptr; + } } - private: - HMODULE dll_; - bool module_loaded_; - UnloadSubModuleFunc unload_module_; + private: + void release() noexcept + { + close_storage(); + if (module_loaded_ && unload_module_ != nullptr) { + unload_module_(); + module_loaded_ = false; + } + unload_module_ = nullptr; + load_module_ = nullptr; + if (dll_ != nullptr) { + static_cast(FreeLibrary(dll_)); + dll_ = nullptr; + } + } + + HMODULE dll_ = nullptr; + bool module_loaded_ = false; + LoadSubModuleFunc load_module_ = nullptr; + UnloadSubModuleFunc unload_module_ = nullptr; module_cbs api_{}; HANDLE storage_ = nullptr; }; + class temporary_output_file final + { + public: + explicit temporary_output_file(std::filesystem::path path) : path_(std::move(path)) + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + ~temporary_output_file() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + temporary_output_file(const temporary_output_file &) = delete; + temporary_output_file &operator=(const temporary_output_file &) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + observer::observer() { modules_.push_back(std::make_unique("renpy.so")); @@ -149,7 +266,7 @@ namespace test std::vector observer::list_files(const std::filesystem::path &path) const { c_module *module = nullptr; - for (const auto &abstract_module: modules_) { + for (const auto &abstract_module : modules_) { const auto candidate = dynamic_cast(abstract_module.get()); REQUIRE(candidate != nullptr); if (candidate->open(path)) { @@ -158,7 +275,353 @@ namespace test } } REQUIRE(module != nullptr); - // ReSharper disable once CppDFANullDereference return module->list_files(); } -} + + TEST_CASE("module ABI: invalid inputs are rejected", "[contract]") + { + constexpr std::array module_names{"renpy.so", "rpgmaker.so", "zanzarah.so"}; + const auto invalid_archive_path = + std::filesystem::temp_directory_path() / + std::format(L"observer-invalid-{}.bin", static_cast(GetCurrentProcessId())); + + for (const auto *module_name : module_names) { + CAPTURE(module_name); + c_module loaded(module_name); + const auto &api = loaded.api(); + + std::string invalid_contents; + if (std::string_view(module_name) == "renpy.so") { + invalid_contents = "RPA-"; + } else if (std::string_view(module_name) == "rpgmaker.so") { + invalid_contents = std::string{"RGSSAD\0\3", 8}; + } else { + invalid_contents.assign(8, '\0'); + } + { + std::ofstream invalid_archive(invalid_archive_path, std::ios::binary | std::ios::trunc); + REQUIRE(invalid_archive.is_open()); + invalid_archive.write(invalid_contents.data(), static_cast(invalid_contents.size())); + } + + StorageGeneralInfo info{}; + HANDLE storage = nullptr; + const std::array signature{std::byte{0xde}, std::byte{0xad}, std::byte{0xbe}, std::byte{0xef}}; + StorageOpenParams params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = invalid_archive_path.c_str(), + .Password = nullptr, + .Data = signature.data(), + .DataSize = signature.size(), + }; + + REQUIRE(api.OpenStorage(params, nullptr, &info) == SOR_INVALID_FILE); + REQUIRE(api.OpenStorage(params, &storage, nullptr) == SOR_INVALID_FILE); + params.FilePath = nullptr; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + params.FilePath = invalid_archive_path.c_str(); + REQUIRE(loaded.load(nullptr) == FALSE); + + storage = INVALID_HANDLE_VALUE; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + REQUIRE(storage == nullptr); + + params.DataSize = 0; + params.FilePath = L"this-file-does-not-exist.observer-test"; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + REQUIRE(storage == nullptr); + + REQUIRE(api.PrepareFiles(nullptr) == FALSE); + REQUIRE(api.GetItem(nullptr, 0, nullptr) == GET_ITEM_ERROR); + REQUIRE(api.ExtractItem(nullptr, {}) == SER_ERROR_SYSTEM); + api.CloseStorage(nullptr); + + params.Data = nullptr; + params.FilePath = invalid_archive_path.c_str(); + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_SUCCESS); + REQUIRE(storage != nullptr); + + REQUIRE(api.PrepareFiles(storage) == FALSE); + REQUIRE(api.GetItem(storage, -1, nullptr) == GET_ITEM_ERROR); + REQUIRE(api.GetItem(storage, 0, nullptr) == GET_ITEM_ERROR); + + StorageItemInfo item{}; + REQUIRE(api.GetItem(storage, 0, &item) == GET_ITEM_NOMOREITEMS); + + ExtractOperationParams extract_params{ + .ItemIndex = -1, + .Flags = 0, + .DestPath = invalid_archive_path.c_str(), + .Password = nullptr, + .Callbacks = {}, + }; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.ItemIndex = 0; + extract_params.DestPath = nullptr; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.DestPath = invalid_archive_path.c_str(); + extract_params.Callbacks.FileProgress = [](void *, int64_t) { return TRUE; }; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.Callbacks.FileProgress = nullptr; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + + api.CloseStorage(storage); + + const auto expect_current_file_prepare_failure = [&] { + StorageOpenParams corrupt_params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = invalid_archive_path.c_str(), + .Password = nullptr, + .Data = nullptr, + .DataSize = 0, + }; + HANDLE corrupt_storage = nullptr; + REQUIRE(api.OpenStorage(corrupt_params, &corrupt_storage, &info) == SOR_SUCCESS); + REQUIRE(corrupt_storage != nullptr); + REQUIRE(api.PrepareFiles(corrupt_storage) == FALSE); + api.CloseStorage(corrupt_storage); + }; + + const auto expect_prepare_failure = [&](const std::string_view contents) { + { + std::ofstream invalid_archive(invalid_archive_path, std::ios::binary | std::ios::trunc); + REQUIRE(invalid_archive.is_open()); + invalid_archive.write(contents.data(), static_cast(contents.size())); + } + expect_current_file_prepare_failure(); + }; + + if (std::string_view(module_name) == "renpy.so") { + expect_prepare_failure("RPA-2.0 0000000000000000\n"); + expect_prepare_failure("RPA-2.0 -000000000000001\n"); + expect_prepare_failure("RPA-2.0 ffffffffffffffff\n"); + expect_prepare_failure("RPA-2.0 0000000000000100\n"); + expect_prepare_failure("RPA-3.0 0000000000000020 0000000000000001\n"); + + const support::temporary_sparse_renpy_archive sparse_archive; + REQUIRE((GetFileAttributesW(sparse_archive.path().c_str()) & FILE_ATTRIBUTE_SPARSE_FILE) != 0); + REQUIRE(std::filesystem::file_size(sparse_archive.path()) > 64ULL * 1024 * 1024); + StorageOpenParams sparse_params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = sparse_archive.path().c_str(), + .Password = nullptr, + .Data = nullptr, + .DataSize = 0, + }; + HANDLE sparse_storage = nullptr; + REQUIRE(api.OpenStorage(sparse_params, &sparse_storage, &info) == SOR_SUCCESS); + REQUIRE(sparse_storage != nullptr); + REQUIRE(api.PrepareFiles(sparse_storage) == FALSE); + api.CloseStorage(sparse_storage); + } else if (std::string_view(module_name) == "zanzarah.so") { + expect_prepare_failure(std::string(4, '\0')); + expect_prepare_failure(std::string{"\0\0\0\0\xff\xff\xff\xff", 8}); + } + } + + std::error_code error; + std::filesystem::remove(invalid_archive_path, error); + } + + TEST_CASE("module ABI: malformed RenPy index shapes are rejected", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &pickle_index) { + const support::temporary_archive archive(label, support::make_renpy_archive_with_index(pickle_index)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("renpy-empty-properties", {'}', 'U', 1, 'x', ']', 's', '.'}); + expect_rejected("renpy-short-tuple", {'}', 'U', 1, 'x', ']', ')', 'a', 's', '.'}); + } + + TEST_CASE("module ABI: RPG Maker rejects declared paths outside its resource bounds", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &contents) { + const support::temporary_archive archive(label, contents); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("rpgmaker-path-budget", {'R', 'G', 'S', 'S', 'A', 'D', 0, 3, 1, 0, 0, 0, 13, 0, + 0, 0, 13, 0, 0, 0, 13, 0, 0, 0, 0xf3, 0xff, 0xff, 0xff}); + expect_rejected("rpgmaker-path-range", {'R', 'G', 'S', 'S', 'A', 'D', 0, 3, 1, 0, 0, 0, 13, 0, + 0, 0, 13, 0, 0, 0, 13, 0, 0, 0, 14, 0, 0, 0}); + } + + TEST_CASE("module ABI: Zanzarah rejects metadata outside its resource and body bounds", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &contents) { + const support::temporary_archive archive(label, contents); + c_module loaded("zanzarah.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("zanzarah-entry-count", {0, 0, 0, 0, 0xff, 0xff, 0xff, 0x7f}); + expect_rejected("zanzarah-entry-range", {0, 0, 0, 0, 1, 0, 0, 0}); + expect_rejected("zanzarah-path-size", + {0, 0, 0, 0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0x7f, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + expect_rejected("zanzarah-path-range", + {0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'}); + expect_rejected("zanzarah-small-block", {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 0, 0, 0, 0, 1, 0, 0, 0}); + expect_rejected("zanzarah-body-range", + {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 64, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 'x'}); + expect_rejected("zanzarah-body-size", + {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0}); + } + + TEST_CASE("module ABI: invalid RenPy body ranges are read errors", "[contract]") + { + const auto expect_read_error = [](const std::string_view label, const support::byte_buffer &pickle_index) { + const support::temporary_archive archive(label, support::make_renpy_archive_with_index(pickle_index)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + const auto destination = archive.path().wstring() + L".out"; + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) { return TRUE; }) == SER_ERROR_READ); + std::error_code error; + std::filesystem::remove(destination, error); + }; + + expect_read_error("renpy-negative-offset", + {'}', 'U', 1, 'x', ']', 'I', '-', '1', '\n', 'K', 1, 0x86, 'a', 's', '.'}); + expect_read_error("renpy-truncated-body", {'}', 'U', 1, 'x', ']', 'K', 0, 'K', 100, 0x86, 'a', 's', '.'}); + } + + TEST_CASE("module ABI: progress cancellation aborts extraction", "[contract]") + { + const std::string payload(std::size_t{256} * 1024, 'x'); + const support::temporary_archive archive("abort", support::make_rpgmaker_archive("abort.txt", payload)); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.list_files().size() == 1); + + const auto destination = archive.path().wstring() + L".out"; + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) { return FALSE; }) == SER_USERABORT); + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) -> int { + throw std::runtime_error("callback"); + }) == SER_ERROR_SYSTEM); + REQUIRE(loaded.extract_file_status(0, std::filesystem::temp_directory_path(), + [](void *, int64_t) { return TRUE; }) == SER_ERROR_WRITE); + + const auto pipe_name = std::format(L"\\\\.\\pipe\\observer-write-failure-{}", GetCurrentProcessId()); + const auto pipe = CreateNamedPipeW(pipe_name.c_str(), PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, 0, 1, 0, nullptr); + REQUIRE(pipe != INVALID_HANDLE_VALUE); + std::jthread pipe_server([pipe] { + static_cast(ConnectNamedPipe(pipe, nullptr)); + static_cast(CloseHandle(pipe)); + }); + REQUIRE(loaded.extract_file_status(0, pipe_name, [](void *, int64_t) { return TRUE; }) == SER_ERROR_WRITE); + + std::error_code error; + std::filesystem::remove(destination, error); + } + + TEST_CASE("module ABI: an item path must fit the ABI buffer", "[contract]") + { + StorageItemInfo item{}; + const std::string oversized_path(std::size(item.Path), 'a'); + const support::temporary_archive archive("oversized-path", + support::make_rpgmaker_archive(oversized_path, "payload")); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + REQUIRE(loaded.api().GetItem(loaded.storage(), 0, &item) == GET_ITEM_ERROR); + } + + TEST_CASE("module ABI: a large valid Zanzarah metadata table is enumerated", "[contract][metadata]") + { + constexpr std::size_t entry_count = 4'096; + const support::temporary_archive archive("zanzarah-large-metadata", + support::make_zanzarah_archive_with_entries(entry_count)); + c_module loaded("zanzarah.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + for (std::size_t index = 0; index < entry_count; ++index) { + StorageItemInfo item{}; + REQUIRE(loaded.api().GetItem(loaded.storage(), static_cast(index), &item) == GET_ITEM_OK); + REQUIRE(item.Size == 1); + REQUIRE(item.PackedSize == 1); + } + + StorageItemInfo item{}; + REQUIRE(loaded.api().GetItem(loaded.storage(), static_cast(entry_count), &item) == GET_ITEM_NOMOREITEMS); + } + + TEST_CASE("module ABI: RenPy rejects an index that expands beyond its metadata budget", "[contract][metadata]") + { + constexpr std::size_t expanded_size = 64ULL * 1024 * 1024 + 1; + const support::temporary_archive archive("renpy-expanded-metadata", + support::make_renpy_archive_with_expanded_index(expanded_size)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + } + + TEST_CASE("package runtime smoke loads an exact unpacked module", "[package-smoke][.]") + { + const auto require_environment = [](const wchar_t *name) { + const auto required_size = GetEnvironmentVariableW(name, nullptr, 0); + if (required_size == 0) { + throw std::runtime_error(std::format("Required package-smoke environment variable is absent: {}", + std::filesystem::path(name).string())); + } + std::wstring value(required_size, L'\0'); + const auto written = GetEnvironmentVariableW(name, value.data(), required_size); + if (written == 0 || written >= required_size) { + throw std::runtime_error("Failed to read a package-smoke environment variable"); + } + value.resize(written); + return value; + }; + + const std::filesystem::path module_path(require_environment(L"OBSERVER_PACKAGE_MODULE")); + const auto format = require_environment(L"OBSERVER_PACKAGE_FORMAT"); + REQUIRE(module_path.is_absolute()); + REQUIRE(std::filesystem::is_regular_file(module_path)); + + support::byte_buffer archive_contents; + std::wstring expected_path; + std::string expected_payload; + if (format == L"renpy") { + expected_path = L"package\\hello.txt"; + expected_payload = "renpy package smoke"; + archive_contents = support::make_renpy_archive("package/hello.txt", expected_payload); + } else if (format == L"rpgmaker") { + expected_path = L"Data\\package.txt"; + expected_payload = "rpgmaker package smoke"; + archive_contents = support::make_rpgmaker_archive("Data\\package.txt", expected_payload); + } else if (format == L"zanzarah") { + expected_path = L"data\\package.txt"; + expected_payload = "zanzarah package smoke"; + archive_contents = support::make_zanzarah_archive("..\\data\\package.txt", expected_payload); + } else { + throw std::runtime_error("OBSERVER_PACKAGE_FORMAT expects renpy, rpgmaker, or zanzarah"); + } + + const support::temporary_archive archive("package-smoke", archive_contents); + c_module loaded(module_path, module_path_policy::exact); + REQUIRE(loaded.open(archive.path())); + const auto files = loaded.list_files(); + REQUIRE(files.size() == 1); + REQUIRE(files.front().path == expected_path); + REQUIRE(files.front().uncompressed_size == static_cast(expected_payload.size())); + REQUIRE(files.front().compressed_size == static_cast(expected_payload.size())); + + const temporary_output_file destination(archive.path().wstring() + L".out"); + REQUIRE(loaded.extract_file_status(0, destination.path(), [](void *, int64_t) { return TRUE; }) == SER_SUCCESS); + loaded.close_storage(); + REQUIRE(loaded.storage() == nullptr); + std::ifstream extracted(destination.path(), std::ios::binary); + REQUIRE(extracted.is_open()); + const std::string actual_payload{std::istreambuf_iterator(extracted), std::istreambuf_iterator()}; + REQUIRE(actual_payload == expected_payload); + } +} // namespace test diff --git a/src/tests/framework/observer.h b/src/tests/framework/observer.h index dddb506..74bbcbf 100644 --- a/src/tests/framework/observer.h +++ b/src/tests/framework/observer.h @@ -7,30 +7,28 @@ namespace test { struct file { - const std::wstring path; - const int64_t uncompressed_size; - const int64_t compressed_size; + std::wstring path; + const int64_t uncompressed_size = 0; + const int64_t compressed_size = 0; const std::function extract; }; class module { - public: - module() - { - }; + public: + module() {}; virtual ~module() = default; }; class observer final { - public: + public: observer(); std::vector list_files(const std::filesystem::path &path) const; - private: - std::vector > modules_; + private: + std::vector> modules_; }; -} +} // namespace test diff --git a/src/tests/framework/testcase.cpp b/src/tests/framework/testcase.cpp index 9684615..7fb1775 100644 --- a/src/tests/framework/testcase.cpp +++ b/src/tests/framework/testcase.cpp @@ -1,3 +1,5 @@ +#include "testcase.h" + #include "observer.h" #include @@ -9,6 +11,28 @@ namespace test { + class temporary_file final + { + public: + explicit temporary_file(std::filesystem::path path) : path_(std::move(path)) + { + } + + ~temporary_file() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + std::string wide_to_utf8(const std::wstring &wide) { const auto wide_size = static_cast(wide.size()); @@ -39,7 +63,7 @@ namespace test REQUIRE(file.is_open()); std::string buffer; - buffer.resize(128 * 1024); + buffer.resize(128ULL * 1024); auto state = XXH3_createState(); REQUIRE(state != nullptr); @@ -55,37 +79,70 @@ namespace test return hash_to_string(hash); } - void test_on(const std::filesystem::path &path) + std::string read_file(const std::filesystem::path &path) + { + std::ifstream input(path, std::ios::binary); + REQUIRE(input.is_open()); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; + } + + void test_archive(const std::filesystem::path &path, const std::vector &expected_files) { - const auto archive_path = std::filesystem::path(R"(M:\observer\_test\)") / path; + const auto plugin = observer(); + const auto files = plugin.list_files(path); + REQUIRE(files.size() == expected_files.size()); + + for (const auto &file : files) { + const auto expected = std::ranges::find(expected_files, file.path, &expected_file::path); + REQUIRE(expected != expected_files.end()); + + temporary_file extracted(make_unique_path(path, file)); + file.extract(extracted.path()); + REQUIRE(file.uncompressed_size >= 0); + REQUIRE(std::filesystem::file_size(extracted.path()) == + static_cast(file.uncompressed_size)); + REQUIRE(read_file(extracted.path()) == expected->contents); + } + } + + void test_external_archive(const std::filesystem::path &path) + { + const auto required_size = GetEnvironmentVariableW(L"OBSERVER_TEST_CORPUS", nullptr, 0); + if (required_size == 0) { + SKIP("OBSERVER_TEST_CORPUS is not set; external golden corpus test skipped"); + } + + std::wstring corpus_root(required_size, L'\0'); + const auto written = GetEnvironmentVariableW(L"OBSERVER_TEST_CORPUS", corpus_root.data(), required_size); + REQUIRE(written > 0); + REQUIRE(written < required_size); + corpus_root.resize(written); + + const auto archive_path = std::filesystem::path(corpus_root) / path; const auto folder = archive_path.parent_path(); std::ifstream expected_listing(folder / std::format(L"{}.expected.json", archive_path.stem().wstring())); REQUIRE(expected_listing.is_open()); auto listing_json = nlohmann::json::parse(expected_listing); - auto expected_strings = listing_json.get >(); + auto expected_strings = listing_json.get>(); std::ranges::sort(expected_strings); const auto plugin = observer(); std::vector actual_strings; - for (const auto files = plugin.list_files(archive_path); const auto &file: files) { - const auto path_on_disk = make_unique_path(archive_path, file); - file.extract(path_on_disk); - const auto file_size = std::filesystem::file_size(path_on_disk); - REQUIRE(file_size == file.uncompressed_size); - actual_strings.emplace_back(std::format("{} {} {}", wide_to_utf8(file.path), file_size, - hash_file(path_on_disk))); - REQUIRE(std::filesystem::remove(path_on_disk)); + for (const auto files = plugin.list_files(archive_path); const auto &file : files) { + temporary_file extracted(make_unique_path(archive_path, file)); + file.extract(extracted.path()); + const auto file_size = std::filesystem::file_size(extracted.path()); + REQUIRE(file.uncompressed_size >= 0); + REQUIRE(file_size == static_cast(file.uncompressed_size)); + actual_strings.emplace_back( + std::format("{} {} {}", wide_to_utf8(file.path), file_size, hash_file(extracted.path()))); } std::ranges::sort(actual_strings); - std::ofstream actual_listing(folder / std::format(L"{}.actual.json", archive_path.stem().wstring())); - REQUIRE(actual_listing.is_open()); - actual_listing << std::setw(4) << nlohmann::json(actual_strings) << std::endl; - REQUIRE(expected_strings.size() == actual_strings.size()); for (size_t i = 0; i < expected_strings.size(); i++) { REQUIRE(expected_strings[i] == actual_strings[i]); } } -} +} // namespace test diff --git a/src/tests/framework/testcase.h b/src/tests/framework/testcase.h index f3cfda8..434225c 100644 --- a/src/tests/framework/testcase.h +++ b/src/tests/framework/testcase.h @@ -1,8 +1,17 @@ #pragma once #include +#include +#include namespace test { - void test_on(const std::filesystem::path &path); -} + struct expected_file final + { + std::wstring path; + std::string contents; + }; + + void test_archive(const std::filesystem::path &path, const std::vector &expected_files); + void test_external_archive(const std::filesystem::path &path); +} // namespace test diff --git a/src/tests/integration/archives.cpp b/src/tests/integration/archives.cpp new file mode 100644 index 0000000..52dbfcf --- /dev/null +++ b/src/tests/integration/archives.cpp @@ -0,0 +1,68 @@ +#include "../framework/testcase.h" +#include "../support/archive_fixtures.h" + +#include + +#include + +TEST_CASE("archives: hermetic RenPy RPA 2.0", "[integration][hermetic]") +{ + const auto contents = test::support::make_renpy_archive("dir/hello.txt", "renpy payload"); + const test::support::temporary_archive archive("renpy", contents); + test::test_archive(archive.path(), {{L"dir\\hello.txt", "renpy payload"}}); +} + +TEST_CASE("archives: hermetic RenPy RPA 3.0", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.version = test::support::renpy_version::rpa_3_0; + const auto contents = test::support::make_renpy_archive("dir/encrypted.txt", "encrypted payload", options); + const test::support::temporary_archive archive("renpy-v3", contents); + test::test_archive(archive.path(), {{L"dir\\encrypted.txt", "encrypted payload"}}); +} + +TEST_CASE("archives: RenPy prepends an indexed header", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.header = "header:"; + const auto contents = test::support::make_renpy_archive("header.txt", "payload", options); + const test::support::temporary_archive archive("renpy-header", contents); + test::test_archive(archive.path(), {{L"header.txt", "header:payload"}}); +} + +TEST_CASE("archives: RenPy accepts an explicit empty header", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.include_none_header = true; + const auto contents = test::support::make_renpy_archive("no-header.txt", "payload", options); + const test::support::temporary_archive archive("renpy-no-header", contents); + test::test_archive(archive.path(), {{L"no-header.txt", "payload"}}); +} + +TEST_CASE("archives: hermetic RPG Maker RGSS3A", "[integration][hermetic]") +{ + const auto contents = test::support::make_rpgmaker_archive("Data\\hello.txt", "rpgmaker payload"); + const test::support::temporary_archive archive("rpgmaker", contents); + test::test_archive(archive.path(), {{L"Data\\hello.txt", "rpgmaker payload"}}); +} + +TEST_CASE("archives: RPG Maker decrypts a partial final word", "[integration][hermetic]") +{ + const auto contents = test::support::make_rpgmaker_archive("Data\\tail.txt", "tail!"); + const test::support::temporary_archive archive("rpgmaker-tail", contents); + test::test_archive(archive.path(), {{L"Data\\tail.txt", "tail!"}}); +} + +TEST_CASE("archives: hermetic Zanzarah PAK", "[integration][hermetic]") +{ + const auto contents = test::support::make_zanzarah_archive("..\\data\\hello.txt", "zanzarah payload"); + const test::support::temporary_archive archive("zanzarah", contents); + test::test_archive(archive.path(), {{L"data\\hello.txt", "zanzarah payload"}}); +} + +TEST_CASE("archives: Zanzarah preserves an already-relative path", "[integration][hermetic]") +{ + const auto contents = test::support::make_zanzarah_archive("data\\direct.txt", "direct payload"); + const test::support::temporary_archive archive("zanzarah-relative", contents); + test::test_archive(archive.path(), {{L"data\\direct.txt", "direct payload"}}); +} diff --git a/src/tests/leaks/probe.cpp b/src/tests/leaks/probe.cpp new file mode 100644 index 0000000..da1541f --- /dev/null +++ b/src/tests/leaks/probe.cpp @@ -0,0 +1,681 @@ +#include "../../api.h" +#include "../support/archive_fixtures.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _DEBUG +#error "leak-probe must be built as the optimized Release executable" +#endif + +namespace +{ + constexpr std::string_view marker_prefix = "OBSERVER_LEAK_PROBE"; + enum class probe_mode : std::uint8_t + { + operations, + lifecycle, + }; + + struct options final + { + std::size_t warmup_rounds = 8; + std::size_t iterations_per_window = 100; + std::size_t windows = 3; + probe_mode mode = probe_mode::operations; + std::string_view scenario = "all"; + bool automatic = false; + }; + + [[nodiscard]] std::size_t parse_count(const std::string_view value, const std::string_view option) + { + std::size_t result = 0; + const auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), result); + if (error != std::errc{} || end != value.data() + value.size() || result == 0 || result > 1'000'000) { + throw std::runtime_error(std::format("{} expects an integer from 1 to 1000000", option)); + } + return result; + } + + [[nodiscard]] options parse_options(const int argc, char **argv) + { + options result; + for (int index = 1; index < argc; ++index) { + const std::string_view argument(argv[index]); + if (argument == "--automatic") { + result.automatic = true; + continue; + } + + if (argument == "--help") { + std::cout << "Usage: leak-probe.exe [--automatic] [--mode operations|lifecycle] " + "[--scenario all|NAME] [--warmup N] [--iterations N] [--windows N]\n"; + std::exit(EXIT_SUCCESS); + } + + if (argument != "--mode" && argument != "--scenario" && argument != "--warmup" && + argument != "--iterations" && argument != "--windows") { + throw std::runtime_error(std::format("Unknown option: {}", argument)); + } + if (++index >= argc) { + throw std::runtime_error(std::format("Missing value after {}", argument)); + } + + if (argument == "--mode") { + const std::string_view mode(argv[index]); + if (mode == "operations") { + result.mode = probe_mode::operations; + } else if (mode == "lifecycle") { + result.mode = probe_mode::lifecycle; + } else { + throw std::runtime_error("--mode expects operations or lifecycle"); + } + } else if (argument == "--scenario") { + result.scenario = argv[index]; + } else if (argument == "--warmup") { + const auto count = parse_count(argv[index], argument); + result.warmup_rounds = count; + } else if (argument == "--iterations") { + const auto count = parse_count(argv[index], argument); + result.iterations_per_window = count; + } else { + const auto count = parse_count(argv[index], argument); + result.windows = count; + } + } + return result; + } + + void suppress_error_dialogs() noexcept + { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + } + + [[nodiscard]] std::filesystem::path executable_directory() + { + std::wstring path(32'768, L'\0'); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) { + throw std::runtime_error("Failed to locate leak-probe.exe"); + } + path.resize(length); + return std::filesystem::path(path).parent_path(); + } + + template [[nodiscard]] Function resolve_export(const HMODULE module, const char *name) + { + const auto address = GetProcAddress(module, name); + if (address == nullptr) { + throw std::runtime_error(std::format("Required module export is absent: {}", name)); + } +#pragma warning(suppress : 4191) + return reinterpret_cast(address); + } + + class storage_handle final + { + public: + storage_handle(const module_cbs &api, const HANDLE value) noexcept : api_(api), value_(value) + { + } + + ~storage_handle() + { + if (value_ != nullptr) { + api_.CloseStorage(value_); + } + } + + storage_handle(const storage_handle &) = delete; + storage_handle &operator=(const storage_handle &) = delete; + storage_handle(storage_handle &&other) noexcept : api_(other.api_), value_(other.value_) + { + other.value_ = nullptr; + } + + [[nodiscard]] HANDLE get() const noexcept + { + return value_; + } + + private: + const module_cbs &api_; + HANDLE value_; + }; + + class temporary_output final + { + public: + explicit temporary_output(std::filesystem::path path) : path_(std::move(path)) + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + ~temporary_output() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + + [[nodiscard]] BOOL CALLBACK report_progress(const HANDLE context, const __int64 bytes_processed) noexcept + { + if (context == nullptr || bytes_processed < 0) { + return FALSE; + } + auto &total = *static_cast<__int64 *>(context); + total += bytes_processed; + return TRUE; + } + + class loaded_module final + { + public: + explicit loaded_module(const std::filesystem::path &path) + : library_(LoadLibraryExW(path.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)) + { + if (library_ == nullptr) { + throw std::runtime_error( + std::format("Failed to load {} (Win32 error {})", path.filename().string(), GetLastError())); + } + + try { + const auto expected_path = std::filesystem::canonical(path); + const auto actual_path = std::filesystem::canonical(module_path(library_)); + if (!std::filesystem::equivalent(expected_path, actual_path)) { + throw std::runtime_error(std::format("Loaded module path mismatch: expected {}, received {}", + expected_path.string(), actual_path.string())); + } + + const auto load = resolve_export(library_, "LoadSubModule"); + unload_ = resolve_export(library_, "UnloadSubModule"); + + ModuleLoadParameters parameters{}; + parameters.StructSize = sizeof(parameters); + if (load(¶meters) == FALSE) { + throw std::runtime_error(std::format("LoadSubModule failed for {}", path.filename().string())); + } + loaded_ = true; + api_ = parameters.ApiFuncs; + + if (parameters.ApiVersion != ACTUAL_API_VERSION || api_.OpenStorage == nullptr || + api_.CloseStorage == nullptr || api_.GetItem == nullptr || api_.ExtractItem == nullptr || + api_.PrepareFiles == nullptr) { + throw std::runtime_error( + std::format("Invalid Observer API table from {}", path.filename().string())); + } + } catch (...) { + release(); + throw; + } + } + + ~loaded_module() + { + release(); + } + + loaded_module(const loaded_module &) = delete; + loaded_module &operator=(const loaded_module &) = delete; + + void exercise_success(const std::filesystem::path &archive_path, const std::wstring_view expected_path, + const std::string_view expected_payload, const std::filesystem::path &output_path) const + { + auto storage = open_archive(archive_path); + + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles failed for a hermetic fixture"); + } + + StorageItemInfo item{}; + if (api_.GetItem(storage.get(), 0, &item) != GET_ITEM_OK) { + throw std::runtime_error("GetItem failed for the only expected fixture entry"); + } + if (std::wstring_view(item.Path) != expected_path || item.Size < 0) { + throw std::runtime_error("The fixture entry metadata is incorrect"); + } + StorageItemInfo unexpected_item{}; + if (api_.GetItem(storage.get(), 1, &unexpected_item) != GET_ITEM_NOMOREITEMS) { + throw std::runtime_error("The fixture unexpectedly contains more than one entry"); + } + + temporary_output output(output_path); + __int64 progress = 0; + const ExtractOperationParams extract_parameters{ + .ItemIndex = 0, + .Flags = 0, + .DestPath = output.path().c_str(), + .Password = nullptr, + .Callbacks = {.signalContext = &progress, .FileProgress = report_progress}, + }; + if (api_.ExtractItem(storage.get(), extract_parameters) != SER_SUCCESS) { + throw std::runtime_error("ExtractItem failed for a hermetic fixture"); + } + + std::ifstream extracted(output.path(), std::ios::binary); + if (!extracted.is_open()) { + throw std::runtime_error("ExtractItem did not create its output file"); + } + const std::string actual_payload{std::istreambuf_iterator(extracted), + std::istreambuf_iterator()}; + if (actual_payload != expected_payload || static_cast(item.Size) != actual_payload.size() || + progress <= 0) { + throw std::runtime_error("Extracted fixture data is incorrect"); + } + } + + void expect_prepare_failure(const std::filesystem::path &archive_path) const + { + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) != FALSE) { + throw std::runtime_error("PrepareFiles unexpectedly accepted a malformed fixture"); + } + } + + void expect_extract_status(const std::filesystem::path &archive_path, const std::filesystem::path &output_path, + const ExtractProgressFunc progress_callback, const int expected_status) const + { + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles rejected an extraction-status fixture"); + } + + __int64 progress = 0; + const ExtractOperationParams extract_parameters{ + .ItemIndex = 0, + .Flags = 0, + .DestPath = output_path.c_str(), + .Password = nullptr, + .Callbacks = {.signalContext = &progress, .FileProgress = progress_callback}, + }; + const auto actual_status = api_.ExtractItem(storage.get(), extract_parameters); + if (actual_status != expected_status) { + throw std::runtime_error( + std::format("ExtractItem returned {}, expected {}", actual_status, expected_status)); + } + } + + void expect_entry_count(const std::filesystem::path &archive_path, const std::size_t expected_count) const + { + if (expected_count > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Expected entry count is outside the Observer ABI range"); + } + + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles rejected a large valid metadata fixture"); + } + for (std::size_t index = 0; index < expected_count; ++index) { + StorageItemInfo item{}; + if (api_.GetItem(storage.get(), static_cast(index), &item) != GET_ITEM_OK || item.Size != 1 || + item.PackedSize != 1 || item.Path[0] == L'\0') { + throw std::runtime_error("GetItem returned invalid large-fixture metadata"); + } + } + StorageItemInfo unexpected_item{}; + if (api_.GetItem(storage.get(), static_cast(expected_count), &unexpected_item) != + GET_ITEM_NOMOREITEMS) { + throw std::runtime_error("The large metadata fixture contains an unexpected extra entry"); + } + } + + private: + [[nodiscard]] static std::filesystem::path module_path(const HMODULE module) + { + std::wstring path(32'768, L'\0'); + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) { + throw std::runtime_error("Failed to resolve the loaded module path"); + } + path.resize(length); + return path; + } + + [[nodiscard]] storage_handle open_archive(const std::filesystem::path &archive_path) const + { + std::ifstream archive(archive_path, std::ios::binary); + if (!archive.is_open()) { + throw std::runtime_error("Failed to open a hermetic archive fixture"); + } + + std::vector signature(std::size_t{128} * 1024); + archive.read(signature.data(), static_cast(signature.size())); + if (archive.bad()) { + throw std::runtime_error("Failed to read a hermetic archive fixture"); + } + + StorageOpenParams open_parameters{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = archive_path.c_str(), + .Password = nullptr, + .Data = signature.data(), + .DataSize = static_cast(archive.gcount()), + }; + StorageGeneralInfo general_info{}; + HANDLE raw_storage = nullptr; + if (api_.OpenStorage(open_parameters, &raw_storage, &general_info) != SOR_SUCCESS || + raw_storage == nullptr) { + throw std::runtime_error("OpenStorage rejected its hermetic fixture"); + } + return storage_handle(api_, raw_storage); + } + + void release() noexcept + { + if (loaded_ && unload_ != nullptr) { + unload_(); + loaded_ = false; + } + unload_ = nullptr; + if (library_ != nullptr) { + FreeLibrary(library_); + library_ = nullptr; + } + } + + HMODULE library_ = nullptr; + bool loaded_ = false; + UnloadSubModuleFunc unload_ = nullptr; + module_cbs api_{}; + }; + + [[nodiscard]] test::support::byte_buffer bytes(const std::string_view value) + { + test::support::byte_buffer result; + result.reserve(value.size()); + std::ranges::transform(value, std::back_inserter(result), [](const char character) { + return static_cast(static_cast(character)); + }); + return result; + } + + [[nodiscard]] BOOL CALLBACK cancel_progress(HANDLE, __int64) noexcept + { + return FALSE; + } + + struct probe_fixtures final + { + static constexpr std::size_t large_entry_count = 4'096; + static constexpr std::size_t expanded_index_size = 64ULL * 1024 * 1024 + 1; + + probe_fixtures() + : temporary_directory(std::filesystem::temp_directory_path()), + renpy_output(temporary_directory / + std::format("observer-leak-probe-renpy-{}.tmp", GetCurrentProcessId())), + rpgmaker_output(temporary_directory / + std::format("observer-leak-probe-rpgmaker-{}.tmp", GetCurrentProcessId())), + zanzarah_output(temporary_directory / + std::format("observer-leak-probe-zanzarah-{}.tmp", GetCurrentProcessId())), + cancellation_output(temporary_directory / + std::format("observer-leak-probe-cancellation-{}.tmp", GetCurrentProcessId())), + read_failure_output(temporary_directory / + std::format("observer-leak-probe-read-failure-{}.tmp", GetCurrentProcessId())), + renpy_archive("renpy", test::support::make_renpy_archive("dir/hello.txt", "renpy payload")), + rpgmaker_archive("rpgmaker", test::support::make_rpgmaker_archive("Data\\hello.txt", "rpgmaker payload")), + zanzarah_archive("zanzarah", + test::support::make_zanzarah_archive("..\\data\\hello.txt", "zanzarah payload")), + malformed_renpy_archive("malformed-renpy", bytes("RPA-2.0 0000000000000000\n")), + malformed_rpgmaker_archive("malformed-rpgmaker", bytes(std::string{"RGSSAD\0\3", 8})), + malformed_zanzarah_archive("malformed-zanzarah", test::support::byte_buffer(8, 0)), + cancellation_archive("cancellation", test::support::make_rpgmaker_archive( + "abort.txt", std::string(std::size_t{256} * 1024, 'x'))), + read_failure_archive("read-failure", + test::support::make_renpy_archive_with_index(test::support::byte_buffer{ + '}', 'U', 1, 'x', ']', 'K', 0, 'K', 100, 0x86, 'a', 's', '.'})), + large_metadata_archive("large-metadata", + test::support::make_zanzarah_archive_with_entries(large_entry_count)), + expanded_metadata_archive("expanded-metadata", + test::support::make_renpy_archive_with_expanded_index(expanded_index_size)) + { + } + + std::filesystem::path temporary_directory; + std::filesystem::path renpy_output; + std::filesystem::path rpgmaker_output; + std::filesystem::path zanzarah_output; + std::filesystem::path cancellation_output; + std::filesystem::path read_failure_output; + test::support::temporary_archive renpy_archive; + test::support::temporary_archive rpgmaker_archive; + test::support::temporary_archive zanzarah_archive; + test::support::temporary_archive malformed_renpy_archive; + test::support::temporary_archive malformed_rpgmaker_archive; + test::support::temporary_archive malformed_zanzarah_archive; + test::support::temporary_archive cancellation_archive; + test::support::temporary_archive read_failure_archive; + test::support::temporary_archive large_metadata_archive; + test::support::temporary_archive expanded_metadata_archive; + test::support::temporary_sparse_renpy_archive sparse_metadata_archive; + }; + + struct module_set final + { + explicit module_set(const std::filesystem::path &binary_directory) + : renpy(binary_directory / "renpy.so"), rpgmaker(binary_directory / "rpgmaker.so"), + zanzarah(binary_directory / "zanzarah.so") + { + } + + loaded_module renpy; + loaded_module rpgmaker; + loaded_module zanzarah; + }; + + struct scenario_context final + { + const module_set &modules; + const probe_fixtures &fixtures; + }; + + void exercise_small_success(const scenario_context &context) + { + context.modules.renpy.exercise_success(context.fixtures.renpy_archive.path(), L"dir\\hello.txt", + "renpy payload", context.fixtures.renpy_output); + context.modules.rpgmaker.exercise_success(context.fixtures.rpgmaker_archive.path(), L"Data\\hello.txt", + "rpgmaker payload", context.fixtures.rpgmaker_output); + context.modules.zanzarah.exercise_success(context.fixtures.zanzarah_archive.path(), L"data\\hello.txt", + "zanzarah payload", context.fixtures.zanzarah_output); + } + + void exercise_malformed(const scenario_context &context) + { + context.modules.renpy.expect_prepare_failure(context.fixtures.malformed_renpy_archive.path()); + context.modules.rpgmaker.expect_prepare_failure(context.fixtures.malformed_rpgmaker_archive.path()); + context.modules.zanzarah.expect_prepare_failure(context.fixtures.malformed_zanzarah_archive.path()); + } + + void exercise_cancellation(const scenario_context &context) + { + const temporary_output output(context.fixtures.cancellation_output); + context.modules.rpgmaker.expect_extract_status(context.fixtures.cancellation_archive.path(), output.path(), + cancel_progress, SER_USERABORT); + } + + void exercise_read_failure(const scenario_context &context) + { + const temporary_output output(context.fixtures.read_failure_output); + context.modules.renpy.expect_extract_status(context.fixtures.read_failure_archive.path(), output.path(), + report_progress, SER_ERROR_READ); + } + + void exercise_write_failure(const scenario_context &context) + { + context.modules.rpgmaker.expect_extract_status(context.fixtures.rpgmaker_archive.path(), + context.fixtures.temporary_directory, report_progress, + SER_ERROR_WRITE); + } + + void exercise_large_metadata(const scenario_context &context) + { + context.modules.zanzarah.expect_entry_count(context.fixtures.large_metadata_archive.path(), + probe_fixtures::large_entry_count); + } + + void exercise_sparse_metadata(const scenario_context &context) + { + context.modules.renpy.expect_prepare_failure(context.fixtures.sparse_metadata_archive.path()); + context.modules.renpy.expect_prepare_failure(context.fixtures.expanded_metadata_archive.path()); + } + + struct scenario_definition final + { + std::string_view name; + void (*exercise)(const scenario_context &) = nullptr; + }; + + constexpr std::array scenario_suite{ + scenario_definition{"small-success", exercise_small_success}, + scenario_definition{"malformed", exercise_malformed}, + scenario_definition{"cancellation", exercise_cancellation}, + scenario_definition{"read-failure", exercise_read_failure}, + scenario_definition{"write-failure", exercise_write_failure}, + scenario_definition{"large-metadata", exercise_large_metadata}, + scenario_definition{"sparse-metadata", exercise_sparse_metadata}, + }; + + [[nodiscard]] const scenario_definition *select_scenario(const std::string_view name) + { + if (name == "all") { + return nullptr; + } + const auto selected = std::ranges::find_if( + scenario_suite, [name](const scenario_definition &scenario) { return scenario.name == name; }); + if (selected == scenario_suite.end()) { + throw std::runtime_error(std::format("Unknown leak scenario: {}", name)); + } + return &*selected; + } + + void exercise_scenario_suite(const module_set &modules, const probe_fixtures &fixtures, + const scenario_definition *const selected_scenario) + { + const scenario_context context{modules, fixtures}; + if (selected_scenario != nullptr) { + selected_scenario->exercise(context); + return; + } + for (const auto &scenario : scenario_suite) { + scenario.exercise(context); + } + } + + void synchronize_snapshot(const std::string_view label, const std::size_t completed_operations, + const bool automatic) + { + std::cout << marker_prefix << "|SNAPSHOT|" << label << "|pid=" << GetCurrentProcessId() + << "|completed_operations=" << completed_operations << '\n' + << std::flush; + if (automatic) { + return; + } + + std::string response; + if (!std::getline(std::cin, response)) { + throw std::runtime_error(std::format("Snapshot {} was not acknowledged", label)); + } + const auto expected = std::format("continue|{}", label); + if (response != expected) { + throw std::runtime_error( + std::format("Expected snapshot acknowledgement '{}', received '{}'", expected, response)); + } + } + + template + void run_measurement_windows(const options &settings, Round &&exercise, const std::size_t operations_per_round) + { + std::size_t completed_operations = 0; + for (std::size_t round = 0; round < settings.warmup_rounds; ++round) { + exercise(); + completed_operations += operations_per_round; + } + synchronize_snapshot("baseline", completed_operations, settings.automatic); + + for (std::size_t window = 1; window <= settings.windows; ++window) { + for (std::size_t iteration = 0; iteration < settings.iterations_per_window; ++iteration) { + exercise(); + completed_operations += operations_per_round; + } + synchronize_snapshot(std::format("window-{}", window), completed_operations, settings.automatic); + } + + std::cout << marker_prefix << "|DONE|pid=" << GetCurrentProcessId() + << "|completed_operations=" << completed_operations << '\n' + << std::flush; + } +} // namespace + +int main(const int argc, char **argv) +{ + suppress_error_dialogs(); + + try { + const auto settings = parse_options(argc, argv); + const auto binary_directory = executable_directory(); + const auto mode_name = settings.mode == probe_mode::operations ? "operations" : "lifecycle"; + const auto *const selected_scenario = select_scenario(settings.scenario); + const auto operations_per_round = selected_scenario == nullptr ? scenario_suite.size() : std::size_t{1}; + const probe_fixtures fixtures; + std::cout << marker_prefix << "|READY|pid=" << GetCurrentProcessId() << "|mode=" << mode_name + << "|configuration=Release|scenarios="; + if (selected_scenario != nullptr) { + std::cout << selected_scenario->name; + } else { + for (std::size_t index = 0; index < scenario_suite.size(); ++index) { + if (index != 0) { + std::cout << ','; + } + std::cout << scenario_suite[index].name; + } + } + std::cout << '\n' << std::flush; + + if (settings.mode == probe_mode::operations) { + const module_set modules(binary_directory); + run_measurement_windows( + settings, + [&modules, &fixtures, selected_scenario] { + exercise_scenario_suite(modules, fixtures, selected_scenario); + }, + operations_per_round); + } else { + const auto exercise_lifecycle = [&binary_directory, &fixtures, selected_scenario] { + const module_set modules(binary_directory); + exercise_scenario_suite(modules, fixtures, selected_scenario); + }; + run_measurement_windows(settings, exercise_lifecycle, operations_per_round); + } + return EXIT_SUCCESS; + } catch (const std::exception &error) { + std::cerr << marker_prefix << "|ERROR|" << error.what() << '\n' << std::flush; + return EXIT_FAILURE; + } catch (...) { + std::cerr << marker_prefix << "|ERROR|unknown failure\n" << std::flush; + return EXIT_FAILURE; + } +} diff --git a/src/tests/main.cpp b/src/tests/main.cpp new file mode 100644 index 0000000..1e3b865 --- /dev/null +++ b/src/tests/main.cpp @@ -0,0 +1,22 @@ +#include + +#include +#include +#include + +int main(int argc, char *argv[]) +{ + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + +#ifdef _DEBUG + _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); +#endif + + return Catch::Session().run(argc, argv); +} diff --git a/src/tests/mutation/main.cpp b/src/tests/mutation/main.cpp new file mode 100644 index 0000000..d14f2b4 --- /dev/null +++ b/src/tests/mutation/main.cpp @@ -0,0 +1,6 @@ +#include + +int main(int argc, char *argv[]) +{ + return Catch::Session().run(argc, argv); +} diff --git a/src/tests/renpy.cpp b/src/tests/renpy.cpp index 161ca37..9c78efe 100644 --- a/src/tests/renpy.cpp +++ b/src/tests/renpy.cpp @@ -4,72 +4,72 @@ using namespace test; -TEST_CASE("renpy: rpa20_binary_hearts") +TEST_CASE("renpy: rpa20_binary_hearts", "[compatibility][.]") { - test_on("renpy\\rpa20_binary_hearts.rpa"); + test_external_archive("renpy\\rpa20_binary_hearts.rpa"); } -TEST_CASE("renpy: rpa30_army_gals") +TEST_CASE("renpy: rpa30_army_gals", "[compatibility][.]") { - test_on("renpy\\rpa30_army_gals.rpa"); + test_external_archive("renpy\\rpa30_army_gals.rpa"); } -TEST_CASE("renpy: rpa30_catch_canvas") +TEST_CASE("renpy: rpa30_catch_canvas", "[compatibility][.]") { - test_on("renpy\\rpa30_catch_canvas.rpa"); + test_external_archive("renpy\\rpa30_catch_canvas.rpa"); } -TEST_CASE("renpy: rpa30_crimson_gray") +TEST_CASE("renpy: rpa30_crimson_gray", "[compatibility][.]") { - test_on("renpy\\rpa30_crimson_gray.rpa"); + test_external_archive("renpy\\rpa30_crimson_gray.rpa"); } -TEST_CASE("renpy: rpa30_crimson_gray_dusk_and_down") +TEST_CASE("renpy: rpa30_crimson_gray_dusk_and_down", "[compatibility][.]") { - test_on("renpy\\rpa30_crimson_gray_dusk_and_down.rpa"); + test_external_archive("renpy\\rpa30_crimson_gray_dusk_and_down.rpa"); } -TEST_CASE("renpy: rpa30_daydream") +TEST_CASE("renpy: rpa30_daydream", "[compatibility][.]") { - test_on("renpy\\rpa30_daydream.rpa"); + test_external_archive("renpy\\rpa30_daydream.rpa"); } -TEST_CASE("renpy: rpa30_doki_doki_high_school_love_time") +TEST_CASE("renpy: rpa30_doki_doki_high_school_love_time", "[compatibility][.]") { - test_on("renpy\\rpa30_doki_doki_high_school_love_time.rpa"); + test_external_archive("renpy\\rpa30_doki_doki_high_school_love_time.rpa"); } -TEST_CASE("renpy: rpa30_dont_take_this_risk") +TEST_CASE("renpy: rpa30_dont_take_this_risk", "[compatibility][.]") { - test_on("renpy\\rpa30_dont_take_this_risk.rpa"); + test_external_archive("renpy\\rpa30_dont_take_this_risk.rpa"); } -TEST_CASE("renpy: rpa30_exiles") +TEST_CASE("renpy: rpa30_exiles", "[compatibility][.]") { - test_on("renpy\\rpa30_exiles.rpa"); + test_external_archive("renpy\\rpa30_exiles.rpa"); } -TEST_CASE("renpy: rpa30_forest") +TEST_CASE("renpy: rpa30_forest", "[compatibility][.]") { - test_on("renpy\\rpa30_forest.rpa"); + test_external_archive("renpy\\rpa30_forest.rpa"); } -TEST_CASE("renpy: rpa30_lucy_got_problems") +TEST_CASE("renpy: rpa30_lucy_got_problems", "[compatibility][.]") { - test_on("renpy\\rpa30_lucy_got_problems.rpa"); + test_external_archive("renpy\\rpa30_lucy_got_problems.rpa"); } -TEST_CASE("renpy: rpa30_national_park_girls") +TEST_CASE("renpy: rpa30_national_park_girls", "[compatibility][.]") { - test_on("renpy\\rpa30_national_park_girls.rpa"); + test_external_archive("renpy\\rpa30_national_park_girls.rpa"); } -TEST_CASE("renpy: rpa30_resort") +TEST_CASE("renpy: rpa30_resort", "[compatibility][.]") { - test_on("renpy\\rpa30_resort.rpa"); + test_external_archive("renpy\\rpa30_resort.rpa"); } -TEST_CASE("renpy: rpa30_the_flower_shop") +TEST_CASE("renpy: rpa30_the_flower_shop", "[compatibility][.]") { - test_on("renpy\\rpa30_the_flower_shop.rpa"); + test_external_archive("renpy\\rpa30_the_flower_shop.rpa"); } diff --git a/src/tests/rpgmaker.cpp b/src/tests/rpgmaker.cpp index 3e7840c..fd080d3 100644 --- a/src/tests/rpgmaker.cpp +++ b/src/tests/rpgmaker.cpp @@ -4,47 +4,47 @@ using namespace test; -TEST_CASE("rpgmaker: GirlsXLust_v1.0") +TEST_CASE("rpgmaker: GirlsXLust_v1.0", "[compatibility][.]") { - test_on("rpgmaker\\GirlsXLust_v1.0.rgss3a"); + test_external_archive("rpgmaker\\GirlsXLust_v1.0.rgss3a"); } -TEST_CASE("rpgmaker: Maid-X-Demon-Mari-s-First-Job") +TEST_CASE("rpgmaker: Maid-X-Demon-Mari-s-First-Job", "[compatibility][.]") { - test_on("rpgmaker\\Maid-X-Demon-Mari-s-First-Job.rgss3a"); + test_external_archive("rpgmaker\\Maid-X-Demon-Mari-s-First-Job.rgss3a"); } -TEST_CASE("rpgmaker: MaiDensnow_Eve") +TEST_CASE("rpgmaker: MaiDensnow_Eve", "[compatibility][.]") { - test_on("rpgmaker\\MaiDensnow_Eve.rgss3a"); + test_external_archive("rpgmaker\\MaiDensnow_Eve.rgss3a"); } -TEST_CASE("rpgmaker: MaidsPerfect_v1.0a") +TEST_CASE("rpgmaker: MaidsPerfect_v1.0a", "[compatibility][.]") { - test_on("rpgmaker\\MaidsPerfect_v1.0a.rgss3a"); + test_external_archive("rpgmaker\\MaidsPerfect_v1.0a.rgss3a"); } -TEST_CASE("rpgmaker: NotSoOrdinaryStory") +TEST_CASE("rpgmaker: NotSoOrdinaryStory", "[compatibility][.]") { - test_on("rpgmaker\\NotSoOrdinaryStory.rgss3a"); + test_external_archive("rpgmaker\\NotSoOrdinaryStory.rgss3a"); } -TEST_CASE("rpgmaker: A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_") +TEST_CASE("rpgmaker: A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_", "[compatibility][.]") { - test_on("rpgmaker\\RJ135050_-_A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_.rgss3a"); + test_external_archive("rpgmaker\\RJ135050_-_A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_.rgss3a"); } -TEST_CASE("rpgmaker: ThroughTheStaticAlpha_v0.1") +TEST_CASE("rpgmaker: ThroughTheStaticAlpha_v0.1", "[compatibility][.]") { - test_on("rpgmaker\\ThroughTheStaticAlpha_v0.1.rgss3a"); + test_external_archive("rpgmaker\\ThroughTheStaticAlpha_v0.1.rgss3a"); } -TEST_CASE("rpgmaker: WarlockAndBoobs_v0.350.1.4") +TEST_CASE("rpgmaker: WarlockAndBoobs_v0.350.1.4", "[compatibility][.]") { - test_on("rpgmaker\\WarlockAndBoobs_v0.350.1.4.rgss3a"); + test_external_archive("rpgmaker\\WarlockAndBoobs_v0.350.1.4.rgss3a"); } -TEST_CASE("rpgmaker: WarlockAndBoobs_v0.422.0.1") +TEST_CASE("rpgmaker: WarlockAndBoobs_v0.422.0.1", "[compatibility][.]") { - test_on("rpgmaker\\WarlockAndBoobs_v0.422.0.1.rgss3a"); + test_external_archive("rpgmaker\\WarlockAndBoobs_v0.422.0.1.rgss3a"); } \ No newline at end of file diff --git a/src/tests/support/archive_fixtures.cpp b/src/tests/support/archive_fixtures.cpp new file mode 100644 index 0000000..6c64092 --- /dev/null +++ b/src/tests/support/archive_fixtures.cpp @@ -0,0 +1,366 @@ +#include "archive_fixtures.h" +#include "zlib_fixture.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace test::support +{ + namespace + { + class unique_handle final + { + public: + explicit unique_handle(const HANDLE value) noexcept : value_(value) + { + } + + ~unique_handle() + { + if (value_ != INVALID_HANDLE_VALUE) { + static_cast(CloseHandle(value_)); + } + } + + unique_handle(const unique_handle &) = delete; + unique_handle &operator=(const unique_handle &) = delete; + + [[nodiscard]] HANDLE get() const noexcept + { + return value_; + } + + private: + HANDLE value_; + }; + + class temporary_path_guard final + { + public: + explicit temporary_path_guard(const std::filesystem::path &path) noexcept : path_(path) + { + } + + ~temporary_path_guard() + { + if (!retained_) { + std::error_code error; + std::filesystem::remove(path_, error); + } + } + + temporary_path_guard(const temporary_path_guard &) = delete; + temporary_path_guard &operator=(const temporary_path_guard &) = delete; + + void retain() noexcept + { + retained_ = true; + } + + private: + const std::filesystem::path &path_; + bool retained_ = false; + }; + + void append_u32(byte_buffer &output, const std::uint32_t value) + { + output.push_back(static_cast(value)); + output.push_back(static_cast(value >> 8)); + output.push_back(static_cast(value >> 16)); + output.push_back(static_cast(value >> 24)); + } + + void append_bytes(byte_buffer &output, const std::string_view value) + { + output.reserve(output.size() + value.size()); + std::ranges::transform(value, std::back_inserter(output), [](const char character) { + return static_cast(static_cast(character)); + }); + } + + [[nodiscard]] std::uint32_t checked_u32(const std::size_t value, const std::string_view description) + { + if (value > std::numeric_limits::max()) { + throw std::runtime_error(std::format("{} does not fit in an archive field", description)); + } + return static_cast(value); + } + + [[nodiscard]] std::uint32_t checked_u32_sum(const std::size_t value, const std::size_t increment, + const std::string_view description) + { + if (value > std::numeric_limits::max() - increment) { + throw std::runtime_error(std::format("{} does not fit in an archive field", description)); + } + return static_cast(value + increment); + } + + [[nodiscard]] std::uint8_t checked_u8(const std::size_t value, const std::string_view description) + { + if (value > std::numeric_limits::max()) { + throw std::runtime_error(std::format("{} does not fit in the minimal Ren'Py fixture", description)); + } + return static_cast(value); + } + + [[nodiscard]] byte_buffer build_renpy_archive(const std::span pickle_index, + const std::string_view payload, const renpy_version version, + const std::uint8_t encryption_key, const std::size_t data_offset) + { + const auto pickle_bytes = std::as_bytes(pickle_index); + const auto compressed = compress_zlib_fixture(pickle_bytes); + + const auto index_offset = data_offset + payload.size(); + byte_buffer archive; + if (version == renpy_version::rpa_2_0) { + append_bytes(archive, std::format("RPA-2.0 {:016x}\n", index_offset)); + } else { + append_bytes(archive, std::format("RPA-3.0 {:016x} {:08x}\n", index_offset, encryption_key)); + } + if (archive.size() > data_offset) { + throw std::runtime_error("Ren'Py fixture data offset overlaps its header"); + } + archive.resize(data_offset, 0); + append_bytes(archive, payload); + std::ranges::transform(compressed, std::back_inserter(archive), + [](const auto byte) { return std::to_integer(byte); }); + return archive; + } + } // namespace + + temporary_archive::temporary_archive(const std::string_view label, const std::span contents) + { + if (contents.size() > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Fixture is too large for std::ofstream"); + } + + static std::atomic_uint32_t sequence = 0; + path_ = std::filesystem::temp_directory_path() / + std::format("observer-modules-{}-{}-{}.bin", label, GetCurrentProcessId(), sequence.fetch_add(1)); + + std::ofstream output(path_, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + throw std::runtime_error(std::format("Failed to create fixture: {}", path_.string())); + } + output.write(reinterpret_cast(contents.data()), static_cast(contents.size())); + if (!output.good()) { + output.close(); + std::error_code error; + std::filesystem::remove(path_, error); + throw std::runtime_error(std::format("Failed to write fixture: {}", path_.string())); + } + } + + temporary_archive::~temporary_archive() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path &temporary_archive::path() const noexcept + { + return path_; + } + + temporary_sparse_renpy_archive::temporary_sparse_renpy_archive() + { + static std::atomic_uint32_t sequence = 0; + path_ = std::filesystem::temp_directory_path() / + std::format("observer-modules-renpy-sparse-{}-{}.bin", GetCurrentProcessId(), sequence.fetch_add(1)); + temporary_path_guard path_guard(path_); + + const unique_handle output( + CreateFileW(path_.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (output.get() == INVALID_HANDLE_VALUE) { + throw std::runtime_error( + std::format("Failed to create sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + + DWORD bytes_returned = 0; + if (DeviceIoControl(output.get(), FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &bytes_returned, nullptr) == + FALSE) { + throw std::runtime_error( + std::format("Failed to mark a Ren'Py fixture sparse (Win32 error {})", GetLastError())); + } + + constexpr std::string_view header_text = "RPA-2.0 0000000000000020\n"; + std::array header{}; + std::ranges::copy(header_text, header.begin()); + DWORD bytes_written = 0; + if (WriteFile(output.get(), header.data(), static_cast(header.size()), &bytes_written, nullptr) == + FALSE || + bytes_written != header.size()) { + throw std::runtime_error( + std::format("Failed to write a sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + + constexpr LONGLONG max_compressed_index_size = 64LL * 1024 * 1024; + const LARGE_INTEGER logical_end{.QuadPart = + static_cast(header.size()) + max_compressed_index_size + 1}; + if (SetFilePointerEx(output.get(), logical_end, nullptr, FILE_BEGIN) == FALSE || + SetEndOfFile(output.get()) == FALSE) { + throw std::runtime_error( + std::format("Failed to size a sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + path_guard.retain(); + } + + temporary_sparse_renpy_archive::~temporary_sparse_renpy_archive() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path &temporary_sparse_renpy_archive::path() const noexcept + { + return path_; + } + + byte_buffer make_zanzarah_archive(const std::string_view path, const std::string_view payload) + { + byte_buffer archive(4, 0); + append_u32(archive, 1); + append_u32(archive, checked_u32(path.size(), "Zanzarah path length")); + append_bytes(archive, path); + append_u32(archive, 0); + append_u32(archive, checked_u32_sum(payload.size(), 4, "Zanzarah payload length")); + append_u32(archive, 0x12345678); + append_bytes(archive, payload); + return archive; + } + + byte_buffer make_zanzarah_archive_with_entries(const std::size_t entry_count) + { + constexpr std::size_t block_size = 5; + if (entry_count == 0 || entry_count > static_cast(std::numeric_limits::max()) || + entry_count > static_cast(std::numeric_limits::max()) / block_size) { + throw std::runtime_error("Zanzarah fixture entry count is outside the representable range"); + } + + byte_buffer archive(4, 0); + append_u32(archive, checked_u32(entry_count, "Zanzarah entry count")); + for (std::size_t index = 0; index < entry_count; ++index) { + const auto path = std::format("data/file-{:06}.bin", index); + append_u32(archive, checked_u32(path.size(), "Zanzarah path length")); + append_bytes(archive, path); + append_u32(archive, checked_u32(index * block_size, "Zanzarah block offset")); + append_u32(archive, static_cast(block_size)); + } + + for (std::size_t index = 0; index < entry_count; ++index) { + append_u32(archive, 0x12345678); + archive.push_back(static_cast('a' + index % 26)); + } + return archive; + } + + byte_buffer make_rpgmaker_archive(const std::string_view path, const std::string_view payload) + { + byte_buffer archive{'R', 'G', 'S', 'S', 'A', 'D', 0, 3}; + constexpr std::uint32_t seed = 1; + constexpr std::uint32_t file_magic = 0x12345678; + constexpr std::uint32_t table_magic = seed * 9 + 3; + const auto data_offset = checked_u32_sum(path.size(), 32, "RPG Maker data offset"); + + append_u32(archive, seed); + append_u32(archive, data_offset ^ table_magic); + append_u32(archive, checked_u32(payload.size(), "RPG Maker payload length") ^ table_magic); + append_u32(archive, file_magic ^ table_magic); + append_u32(archive, checked_u32(path.size(), "RPG Maker path length") ^ table_magic); + for (std::size_t index = 0; index < path.size(); ++index) { + const auto key = static_cast(table_magic >> (8 * (index % 4))); + archive.push_back(static_cast(static_cast(path[index])) ^ key); + } + append_u32(archive, table_magic); + + byte_buffer encrypted; + encrypted.reserve(payload.size()); + append_bytes(encrypted, payload); + auto magic = file_magic; + std::size_t index = 0; + while (index + 4 <= encrypted.size()) { + for (std::size_t byte_index = 0; byte_index < 4; ++byte_index) { + encrypted[index + byte_index] ^= static_cast(magic >> (8 * byte_index)); + } + magic = magic * 7 + 3; + index += 4; + } + while (index < encrypted.size()) { + encrypted[index] ^= static_cast(magic >> (8 * (index % 4))); + ++index; + } + archive.insert(archive.end(), encrypted.begin(), encrypted.end()); + return archive; + } + + byte_buffer make_renpy_archive(const std::string_view path, const std::string_view payload) + { + return make_renpy_archive(path, payload, {}); + } + + byte_buffer make_renpy_archive(const std::string_view path, const std::string_view payload, + const renpy_archive_options &options) + { + constexpr std::uint8_t tuple2 = 0x86; + constexpr std::uint8_t tuple3 = 0x87; + const std::size_t data_offset = options.version == renpy_version::rpa_2_0 ? 32 : 64; + const std::size_t header_size = options.header ? options.header->size() : 0; + + const auto encoded_path_size = checked_u8(path.size(), "path length"); + const auto encoded_header_size = checked_u8(header_size, "header length"); + auto encoded_offset = checked_u8(data_offset, "data offset"); + auto encoded_body_size = checked_u8(payload.size() + header_size, "body length"); + if (options.version == renpy_version::rpa_3_0) { + encoded_offset ^= options.encryption_key; + encoded_body_size ^= options.encryption_key; + } + + byte_buffer pickle; + pickle.push_back('}'); + pickle.push_back('U'); + pickle.push_back(encoded_path_size); + append_bytes(pickle, path); + pickle.push_back(']'); + pickle.push_back('K'); + pickle.push_back(encoded_offset); + pickle.push_back('K'); + pickle.push_back(encoded_body_size); + if (options.header) { + pickle.push_back('C'); + pickle.push_back(encoded_header_size); + append_bytes(pickle, *options.header); + } else if (options.include_none_header) { + pickle.push_back('N'); + } + pickle.push_back(options.header || options.include_none_header ? tuple3 : tuple2); + pickle.push_back('a'); + pickle.push_back('s'); + pickle.push_back('.'); + + return build_renpy_archive(pickle, payload, options.version, options.encryption_key, data_offset); + } + + byte_buffer make_renpy_archive_with_index(const std::span pickle_index, + const std::string_view payload) + { + return build_renpy_archive(pickle_index, payload, renpy_version::rpa_2_0, 0, 32); + } + + byte_buffer make_renpy_archive_with_expanded_index(const std::size_t expanded_size) + { + if (expanded_size == 0) { + throw std::runtime_error("Expanded Ren'Py index fixture must not be empty"); + } + return make_renpy_archive_with_index(byte_buffer(expanded_size, static_cast('N'))); + } +} // namespace test::support diff --git a/src/tests/support/archive_fixtures.h b/src/tests/support/archive_fixtures.h new file mode 100644 index 0000000..72bb3d4 --- /dev/null +++ b/src/tests/support/archive_fixtures.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace test::support +{ + using byte_buffer = std::vector; + + enum class renpy_version : std::uint8_t + { + rpa_2_0, + rpa_3_0, + }; + + struct renpy_archive_options final + { + renpy_version version = renpy_version::rpa_2_0; + std::uint8_t encryption_key = 0x5a; + std::optional header; + bool include_none_header = false; + }; + + [[nodiscard]] byte_buffer make_renpy_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_renpy_archive(std::string_view path, std::string_view payload, + const renpy_archive_options &options); + [[nodiscard]] byte_buffer make_renpy_archive_with_index(std::span pickle_index, + std::string_view payload = {}); + [[nodiscard]] byte_buffer make_renpy_archive_with_expanded_index(std::size_t expanded_size); + [[nodiscard]] byte_buffer make_rpgmaker_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_zanzarah_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_zanzarah_archive_with_entries(std::size_t entry_count); + + class temporary_archive final + { + public: + temporary_archive(std::string_view label, std::span contents); + ~temporary_archive(); + + temporary_archive(const temporary_archive &) = delete; + temporary_archive &operator=(const temporary_archive &) = delete; + temporary_archive(temporary_archive &&) = delete; + temporary_archive &operator=(temporary_archive &&) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept; + + private: + std::filesystem::path path_; + }; + + class temporary_sparse_renpy_archive final + { + public: + temporary_sparse_renpy_archive(); + ~temporary_sparse_renpy_archive(); + + temporary_sparse_renpy_archive(const temporary_sparse_renpy_archive &) = delete; + temporary_sparse_renpy_archive &operator=(const temporary_sparse_renpy_archive &) = delete; + temporary_sparse_renpy_archive(temporary_sparse_renpy_archive &&) = delete; + temporary_sparse_renpy_archive &operator=(temporary_sparse_renpy_archive &&) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept; + + private: + std::filesystem::path path_; + }; +} // namespace test::support diff --git a/src/tests/support/zlib_fixture.cpp b/src/tests/support/zlib_fixture.cpp new file mode 100644 index 0000000..f3847dc --- /dev/null +++ b/src/tests/support/zlib_fixture.cpp @@ -0,0 +1,27 @@ +#include "zlib_fixture.h" + +#include +#include + +#include + +namespace test::support +{ + std::vector compress_zlib_fixture(const std::span input) + { + if (input.size() > std::numeric_limits::max()) { + throw std::runtime_error("Fixture input is too large for zlib compression"); + } + + auto output_size = compressBound(static_cast(input.size())); + std::vector output(output_size); + const auto result = + compress2(reinterpret_cast(output.data()), &output_size, + reinterpret_cast(input.data()), static_cast(input.size()), Z_BEST_SPEED); + if (result != Z_OK) { + throw std::runtime_error("Failed to compress zlib fixture"); + } + output.resize(output_size); + return output; + } +} // namespace test::support diff --git a/src/tests/support/zlib_fixture.h b/src/tests/support/zlib_fixture.h new file mode 100644 index 0000000..bb6c9a6 --- /dev/null +++ b/src/tests/support/zlib_fixture.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include + +namespace test::support +{ + [[nodiscard]] std::vector compress_zlib_fixture(std::span input); +} diff --git a/src/tests/unit/bounded_stream.cpp b/src/tests/unit/bounded_stream.cpp new file mode 100644 index 0000000..9c46447 --- /dev/null +++ b/src/tests/unit/bounded_stream.cpp @@ -0,0 +1,220 @@ +#include "../../core/io/bounded_stream.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + class controlled_stream_buffer final : public std::streambuf + { + public: + explicit controlled_stream_buffer(const std::string_view contents, const std::streamoff position = 0) + : contents_(contents), position_(position) + { + } + + void fail_seeks(const bool value) noexcept + { + fail_seeks_ = value; + } + + void limit_reads(const bool value) noexcept + { + limit_reads_ = value; + } + + void set_position(const std::streamoff value) noexcept + { + position_ = value; + } + + protected: + pos_type seekoff(const off_type offset, const std::ios_base::seekdir direction, + const std::ios_base::openmode mode) override + { + if (fail_seeks_ || (mode & std::ios_base::in) == 0) { + return pos_type{off_type{-1}}; + } + + off_type base = 0; + if (direction == std::ios_base::cur) { + base = position_; + } else if (direction == std::ios_base::end) { + base = static_cast(contents_.size()); + } + const auto result = base + offset; + if (result < 0) { + return pos_type{off_type{-1}}; + } + position_ = result; + return pos_type{position_}; + } + + pos_type seekpos(const pos_type position, const std::ios_base::openmode mode) override + { + if (fail_seeks_ || (mode & std::ios_base::in) == 0 || position < 0) { + return pos_type{off_type{-1}}; + } + position_ = static_cast(position); + return pos_type{position_}; + } + + std::streamsize xsgetn(char_type *destination, const std::streamsize count) override + { + if (count <= 0 || position_ < 0 || position_ >= static_cast(contents_.size())) { + return 0; + } + const auto available = static_cast(contents_.size() - static_cast(position_)); + auto copied = std::min(count, available); + if (limit_reads_) { + copied = std::min(copied, 1); + } + std::memcpy(destination, contents_.data() + position_, static_cast(copied)); + position_ += copied; + return copied; + } + + private: + std::string contents_; + std::streamoff position_ = 0; + bool fail_seeks_ = false; + bool limit_reads_ = false; + }; +} // namespace + +TEST_CASE("bounded stream: reads and rejects out-of-range operations") +{ + std::istringstream source("abcd"); + observer::io::bounded_stream input(source); + REQUIRE(input.size() == 4); + REQUIRE(input.position() == 0); + + input.seek_absolute(1); + REQUIRE(input.remaining() == 3); + std::array value{}; + input.read_exact(value.data(), value.size()); + REQUIRE(value == std::array{'b', 'c'}); + REQUIRE(input.remaining() == 1); + + REQUIRE_THROWS_AS(input.read_exact(value.data(), value.size()), observer::io::read_error); + REQUIRE_THROWS_AS(input.seek_absolute(-1), observer::io::read_error); + REQUIRE_THROWS_AS(input.seek_absolute(5), observer::io::read_error); +} + +TEST_CASE("bounded stream: reads trivial values") +{ + std::istringstream source(std::string{"\x78\x56\x34\x12", 4}); + observer::io::bounded_stream input(source); + REQUIRE(input.read_trivial() == 0x12345678); +} + +TEST_CASE("bounded stream: normalizes stream positioning failures") +{ + SECTION("invalid initial position") + { + controlled_stream_buffer buffer("abc", -1); + std::istream source(&buffer); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("end precedes initial position") + { + controlled_stream_buffer buffer("", 1); + std::istream source(&buffer); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("constructor receives an exception") + { + controlled_stream_buffer buffer("abc"); + buffer.fail_seeks(true); + std::istream source(&buffer); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("position is negative") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + source.setstate(std::ios_base::failbit); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } + + SECTION("position exceeds the captured size") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.set_position(4); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } + + SECTION("position receives an exception") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(source.setstate(std::ios_base::failbit), std::ios_base::failure); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } +} + +TEST_CASE("bounded stream: normalizes seek and read failures") +{ + SECTION("seek reports a failed stream state") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.fail_seeks(true); + REQUIRE_THROWS_AS(input.seek_absolute(0), observer::io::read_error); + } + + SECTION("seek throws") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.fail_seeks(true); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(input.seek_absolute(0), observer::io::read_error); + } + + SECTION("read is shorter than the captured extent") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.limit_reads(true); + std::array output{}; + REQUIRE_THROWS_AS(input.read_exact(output.data(), output.size()), observer::io::read_error); + } + + SECTION("read throws") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.limit_reads(true); + source.exceptions(std::ios_base::failbit); + std::array output{}; + REQUIRE_THROWS_AS(input.read_exact(output.data(), output.size()), observer::io::read_error); + } + + SECTION("requested size cannot be represented by the stream API") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + const auto impossible_size = static_cast(std::numeric_limits::max()) + 1; + REQUIRE_THROWS_AS(input.read_exact(nullptr, impossible_size), observer::io::read_error); + } +} diff --git a/src/tests/unit/pickle.cpp b/src/tests/unit/pickle.cpp new file mode 100644 index 0000000..26a35fb --- /dev/null +++ b/src/tests/unit/pickle.cpp @@ -0,0 +1,249 @@ +#include "../../modules/renpy/pickle.h" +#include "../../archive.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + template pickle::value_ptr load(const std::array &input) + { + return pickle::loads(std::span{ + reinterpret_cast(input.data()), + input.size(), + }); + } + + pickle::value_ptr load(const std::initializer_list input) + { + const std::vector bytes(input); + return pickle::loads(std::span{ + reinterpret_cast(bytes.data()), + bytes.size(), + }); + } + + pickle::value_ptr load_memo_copy(const std::initializer_list encoded_value) + { + std::vector bytes{']'}; + bytes.insert(bytes.end(), encoded_value); + const std::array suffix{'q', 0, 'a', 'h', 0, 'a'}; + bytes.insert(bytes.end(), suffix.begin(), suffix.end()); + bytes.push_back('.'); + return pickle::loads(std::span{ + reinterpret_cast(bytes.data()), + bytes.size(), + }); + } +} // namespace + +TEST_CASE("pickle: scalar values") +{ + const auto none = pickle::loads(std::string{"N."}); + REQUIRE(none->get_type() == pickle::value::type::none); + + const auto true_value = load(std::array{0x80, 0x04, 0x88, '.'}); + REQUIRE(true_value->get_type() == pickle::value::type::bool_); + REQUIRE(true_value->as_bool()); + + const auto false_value = load(std::array{0x80, 0x04, 0x89, '.'}); + REQUIRE(false_value->get_type() == pickle::value::type::bool_); + REQUIRE_FALSE(false_value->as_bool()); + + const auto integer = load(std::array{'K', 42, '.'}); + REQUIRE(integer->get_type() == pickle::value::type::int64); + REQUIRE(integer->as_int64() == 42); + + const auto float_value = load(std::array{'G', 0x3f, 0xf8, 0, 0, 0, 0, 0, 0, '.'}); + REQUIRE(float_value->as_float64() == 1.5); +} + +TEST_CASE("pickle: strings and lists") +{ + const auto string = load(std::array{'U', 3, 'f', 'o', 'o', '.'}); + REQUIRE(string->get_type() == pickle::value::type::string); + REQUIRE(string->as_string() == "foo"); + + const auto list = load(std::array{']', '(', 'K', 1, 'K', 2, 'e', '.'}); + REQUIRE(list->get_type() == pickle::value::type::list); + REQUIRE(list->as_list().size() == 2); + REQUIRE(list->as_list()[0]->as_int64() == 1); + REQUIRE(list->as_list()[1]->as_int64() == 2); +} + +TEST_CASE("pickle: integer and protocol encodings") +{ + REQUIRE(load({'J', 0x78, 0x56, 0x34, 0x12, '.'})->as_int64() == 0x12345678); + REQUIRE(load({'J', 0xff, 0xff, 0xff, 0xff, '.'})->as_int64() == -1); + REQUIRE(load({'M', 0x34, 0x12, '.'})->as_int64() == 0x1234); + REQUIRE(load({'I', '4', '2', '\n', '.'})->as_int64() == 42); + REQUIRE(load({'I', '-', '4', '2', 'L', '\n', '.'})->as_int64() == -42); + + REQUIRE(load({0x80, 4, 0x95, 0, 0, 0, 0, 0, 0, 0, 0, 'K', 7, '.'})->as_int64() == 7); +} + +TEST_CASE("pickle: string and byte encodings") +{ + const auto bin_string = load({'T', 3, 0, 0, 0, 'f', 'o', 'o', '.'}); + REQUIRE(bin_string->get_type() == pickle::value::type::string); + REQUIRE(bin_string->as_string() == "foo"); + + REQUIRE(load({0x8c, 3, 'b', 'a', 'r', '.'})->as_string() == "bar"); + REQUIRE(load({'X', 3, 0, 0, 0, 'b', 'a', 'z', '.'})->as_string() == "baz"); + + const auto short_bytes = load({'C', 2, 0, 0xff, '.'}); + REQUIRE(short_bytes->get_type() == pickle::value::type::bytes); + REQUIRE(short_bytes->as_string() == std::string{"\0\xff", 2}); + + const auto bin_bytes = load({'B', 3, 0, 0, 0, 1, 2, 3, '.'}); + REQUIRE(bin_bytes->get_type() == pickle::value::type::bytes); + REQUIRE(bin_bytes->as_string() == std::string{"\1\2\3", 3}); +} + +TEST_CASE("pickle: list and tuple encodings") +{ + REQUIRE(load({']', 'K', 1, 'a', '.'})->as_list()[0]->as_int64() == 1); + REQUIRE(load({'(', 'K', 1, 'K', 2, 'l', '.'})->as_list().size() == 2); + + REQUIRE(load({')', '.'})->as_tuple().empty()); + REQUIRE(load({'(', 'K', 1, 't', '.'})->as_tuple()[0]->as_int64() == 1); + REQUIRE(load({'K', 1, 0x85, '.'})->as_tuple()[0]->as_int64() == 1); + + const auto tuple2 = load({'K', 1, 'K', 2, 0x86, '.'}); + REQUIRE(tuple2->as_tuple().size() == 2); + REQUIRE(tuple2->as_tuple()[1]->as_int64() == 2); + + const auto tuple3 = load({'K', 1, 'K', 2, 'K', 3, 0x87, '.'}); + REQUIRE(tuple3->as_tuple().size() == 3); + REQUIRE(tuple3->as_tuple()[2]->as_int64() == 3); +} + +TEST_CASE("pickle: dictionary encodings") +{ + REQUIRE(load({'}', '.'})->as_dict().empty()); + + const auto dict = load({'(', 'U', 1, 'a', 'K', 1, 'd', '.'}); + REQUIRE(dict->as_dict().at("a")->as_int64() == 1); + + const auto setitem = load({'}', 'U', 1, 'a', 'K', 2, 's', '.'}); + REQUIRE(setitem->as_dict().at("a")->as_int64() == 2); + + const auto setitems = load({'}', '(', 'U', 1, 'a', 'K', 3, 'U', 1, 'b', 'K', 4, 'u', '.'}); + REQUIRE(setitems->as_dict().size() == 2); + REQUIRE(setitems->as_dict().at("a")->as_int64() == 3); + REQUIRE(setitems->as_dict().at("b")->as_int64() == 4); +} + +TEST_CASE("pickle: LONG1 integers") +{ + REQUIRE(load(std::array{0x8a, 0, '.'})->as_int64() == 0); + REQUIRE(load(std::array{0x8a, 1, 0x7f, '.'})->as_int64() == 127); + REQUIRE(load(std::array{0x8a, 1, 0xff, '.'})->as_int64() == -1); + REQUIRE(load({0x8a, 8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, '.'})->as_int64() == INT64_MAX); + + REQUIRE_THROWS(load(std::array{0x8a, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, '.'})); +} + +TEST_CASE("pickle: malformed input is rejected") +{ + REQUIRE_THROWS(pickle::loads(std::string{})); + REQUIRE_THROWS(pickle::loads(std::string{"?."})); + REQUIRE_THROWS(pickle::loads(std::string{"I\n"})); + + REQUIRE_THROWS(load({'M', 1})); + REQUIRE_THROWS(load({'J', 1, 2, 3})); + REQUIRE_THROWS(load({'G', 0, 0, 0, 0, 0, 0, 0})); + REQUIRE_THROWS(load({'U', 2, 'x'})); + REQUIRE_THROWS(load({'I', '1'})); + REQUIRE_THROWS(load({'N', 'N', '.'})); + + REQUIRE_THROWS(load({'a'})); + REQUIRE_THROWS(load({'N', 'K', 1, 'a'})); + REQUIRE_THROWS(load({'(', 'K', 1, 'e'})); + REQUIRE_THROWS_AS(load({']', 'N', '(', 'a', 'l'}), std::runtime_error); + REQUIRE_THROWS(load({'N', '(', 'K', 1, 'e'})); + REQUIRE_THROWS(load({'l'})); + + REQUIRE_THROWS(load({0x85})); + REQUIRE_THROWS(load({'K', 1, 0x86})); + REQUIRE_THROWS(load({'K', 1, 'K', 2, 0x87})); + + REQUIRE_THROWS(load({'(', 'U', 1, 'a', 'd'})); + REQUIRE_THROWS(load({'(', 'K', 1, 'K', 2, 'd'})); + REQUIRE_THROWS(load({'s'})); + REQUIRE_THROWS(load({'N', 'U', 1, 'a', 'K', 1, 's'})); + REQUIRE_THROWS(load({'}', 'K', 1, 'K', 2, 's'})); + REQUIRE_THROWS(load({'}', '(', 'U', 1, 'a', 'u'})); + REQUIRE_THROWS(load({'(', 'U', 1, 'a', 'K', 1, 'u'})); + REQUIRE_THROWS(load({'N', '(', 'U', 1, 'a', 'K', 1, 'u'})); + REQUIRE_THROWS(load({'}', '(', 'K', 1, 'K', 2, 'u'})); + + REQUIRE_THROWS(load({'q'})); + REQUIRE_THROWS(load({'q', 0})); + REQUIRE_THROWS(load({'r', 0, 0, 0, 0})); + REQUIRE_THROWS(load({'h', 0})); + REQUIRE_THROWS(load({'j', 0, 0, 0, 0})); + REQUIRE_THROWS(load({0x94})); + REQUIRE_THROWS(load({0x80})); + REQUIRE_THROWS(load({0x95, 0, 0, 0, 0, 0, 0, 0})); + REQUIRE_THROWS(load({0x8a})); + REQUIRE_THROWS(load({0x8a, 1})); + + const auto none = pickle::value::none(); + REQUIRE_THROWS(none->as_bool()); + REQUIRE_THROWS(none->as_int64()); + REQUIRE_THROWS(none->as_float64()); + REQUIRE_THROWS(none->as_string()); + REQUIRE_THROWS(none->as_list()); + REQUIRE_THROWS(none->as_tuple()); + REQUIRE_THROWS(none->as_dict()); +} + +TEST_CASE("pickle: memo references preserve values") +{ + const auto list = load(std::array{']', 'K', 42, 'q', 7, 'a', 'h', 7, 'a', '.'}); + + REQUIRE(list->as_list().size() == 2); + REQUIRE(list->as_list()[0]->as_int64() == 42); + REQUIRE(list->as_list()[1]->as_int64() == 42); + + const auto long_memo = load({']', 'K', 9, 'r', 1, 0, 0, 0, 'a', 'j', 1, 0, 0, 0, 'a', '.'}); + REQUIRE(long_memo->as_list()[1]->as_int64() == 9); + + const auto auto_memo = load({']', 'K', 8, 0x94, 'a', 'h', 0, 'a', '.'}); + REQUIRE(auto_memo->as_list()[1]->as_int64() == 8); +} + +TEST_CASE("pickle: memo copies all supported value types") +{ + REQUIRE(load_memo_copy({'N'})->as_list()[1]->get_type() == pickle::value::type::none); + REQUIRE(load_memo_copy({0x88})->as_list()[1]->as_bool()); + REQUIRE(load_memo_copy({'K', 2})->as_list()[1]->as_int64() == 2); + REQUIRE(load_memo_copy({'G', 0x3f, 0xf0, 0, 0, 0, 0, 0, 0})->as_list()[1]->as_float64() == 1.0); + REQUIRE(load_memo_copy({'C', 1, 'b'})->as_list()[1]->get_type() == pickle::value::type::bytes); + REQUIRE(load_memo_copy({'U', 1, 's'})->as_list()[1]->get_type() == pickle::value::type::string); + REQUIRE(load_memo_copy({']', 'K', 1, 'a'})->as_list()[1]->as_list()[0]->as_int64() == 1); + REQUIRE(load_memo_copy({'}', 'U', 1, 'k', 'K', 1, 's'})->as_list()[1]->as_dict().at("k")->as_int64() == 1); + REQUIRE(load_memo_copy({'K', 1, 0x85})->as_list()[1]->as_tuple()[0]->as_int64() == 1); + + const pickle::value invalid(std::bit_cast(UINT8_MAX)); + REQUIRE_THROWS(pickle::clone(invalid)); +} + +TEST_CASE("archive: typed failures are standard exceptions") +{ + REQUIRE(std::string_view(archive::read_error{}.what()).empty()); + REQUIRE(std::string_view(archive::write_error{}.what()).empty()); + REQUIRE(std::string_view(archive::user_interrupt{}.what()).empty()); + REQUIRE(std::string_view(extractor::read_error{}.what()).empty()); + + const auto base = std::make_unique(); + REQUIRE(base != nullptr); +} diff --git a/src/tests/unit/zlib.cpp b/src/tests/unit/zlib.cpp new file mode 100644 index 0000000..72110ed --- /dev/null +++ b/src/tests/unit/zlib.cpp @@ -0,0 +1,118 @@ +#include "../../core/compression/zlib_codec.h" +#include "../support/zlib_fixture.h" + +#include +#include +#include + +#ifdef _DEBUG +#include +#endif + +#include + +namespace +{ +#ifdef _DEBUG + thread_local bool reject_next_allocation = false; + + int __cdecl allocation_hook(const int allocation_type, void *, const std::size_t, const int, const long, + const unsigned char *, const int) + { + if (allocation_type == _HOOK_ALLOC && reject_next_allocation) { + reject_next_allocation = false; + return 0; + } + return 1; + } + + class scoped_allocation_failure final + { + public: + scoped_allocation_failure() : previous_(_CrtSetAllocHook(allocation_hook)) + { + reject_next_allocation = true; + } + + ~scoped_allocation_failure() + { + reject_next_allocation = false; + static_cast(_CrtSetAllocHook(previous_)); + } + + scoped_allocation_failure(const scoped_allocation_failure &) = delete; + scoped_allocation_failure &operator=(const scoped_allocation_failure &) = delete; + + private: + _CRT_ALLOC_HOOK previous_ = nullptr; + }; + + void decompress_with_failed_initial_allocation(const std::span input) + { + scoped_allocation_failure failure; + static_cast(observer::compression::decompress_zlib(input, 1)); + } +#endif +} // namespace + +TEST_CASE("compression: zlib round trip and output budget") +{ + constexpr std::array input{ + std::byte{0x6f}, std::byte{0x62}, std::byte{0x73}, std::byte{0x65}, + std::byte{0x72}, std::byte{0x76}, std::byte{0x65}, std::byte{0x72}, + }; + const auto compressed = test::support::compress_zlib_fixture(input); + REQUIRE(observer::compression::decompress_zlib(compressed, input.size()) == + std::vector(input.begin(), input.end())); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(compressed, input.size() - 1), + observer::compression::error); + + auto corrupted = compressed; + corrupted.back() ^= std::byte{0xff}; + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(corrupted, input.size()), observer::compression::error); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib({}, input.size()), observer::compression::error); +} + +TEST_CASE("compression: zlib consumes multiple output chunks and rejects trailing or truncated input") +{ + std::vector input(std::size_t{192} * 1024); + std::uint32_t state = 0x9e3779b9; + for (auto &byte : input) { + state = state * 1664525 + 1013904223; + byte = static_cast(state >> 24); + } + + const auto compressed = test::support::compress_zlib_fixture(input); + REQUIRE(observer::compression::decompress_zlib(compressed, input.size()) == input); + + auto trailing = compressed; + trailing.push_back(std::byte{0}); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(trailing, input.size()), observer::compression::error); + + auto truncated = compressed; + truncated.pop_back(); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(truncated, input.size()), observer::compression::error); +} + +TEST_CASE("compression: zlib supports an empty payload") +{ + const auto compressed = test::support::compress_zlib_fixture({}); + REQUIRE(observer::compression::decompress_zlib(compressed, 0).empty()); +} + +TEST_CASE("compression: zlib normalizes a stream that requires a preset dictionary") +{ + constexpr std::array compressed_with_dictionary{ + std::byte{0x78}, std::byte{0xbb}, std::byte{0}, std::byte{0}, std::byte{0}, std::byte{1}, + }; + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(compressed_with_dictionary, 0), + observer::compression::error); +} + +#ifdef _DEBUG +TEST_CASE("compression: zlib initialization failure is normalized") +{ + const auto compressed = test::support::compress_zlib_fixture(std::array{std::byte{1}}); + REQUIRE_THROWS_AS(decompress_with_failed_initial_allocation(compressed), observer::compression::error); +} +#endif diff --git a/src/tests/zanzarah.cpp b/src/tests/zanzarah.cpp index a087276..7a6f7a2 100644 --- a/src/tests/zanzarah.cpp +++ b/src/tests/zanzarah.cpp @@ -4,17 +4,17 @@ using namespace test; -TEST_CASE("zanzarah: zanzarah1") +TEST_CASE("zanzarah: zanzarah1", "[compatibility][.]") { - test_on("zanzarah\\zanzarah1.pak"); + test_external_archive("zanzarah\\zanzarah1.pak"); } -TEST_CASE("zanzarah: zanzarah2") +TEST_CASE("zanzarah: zanzarah2", "[compatibility][.]") { - test_on("zanzarah\\zanzarah2.pak"); + test_external_archive("zanzarah\\zanzarah2.pak"); } -TEST_CASE("zanzarah: zanzarah3") +TEST_CASE("zanzarah: zanzarah3", "[compatibility][.]") { - test_on("zanzarah\\zanzarah3.pak"); + test_external_archive("zanzarah\\zanzarah3.pak"); } diff --git a/vcpkg.json b/vcpkg.json index 5b8ac59..4efc9ef 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,22 +1,24 @@ { - "name" : "observer-modules", - "version-string" : "1.0.0", - "license" : "LGPL-3.0-or-later", - "dependencies" : [ { - "name" : "zlib", - "version>=" : "1.3.1" - }, { - "name" : "zstr", - "version>=" : "1.0.7" - }, { - "name" : "catch2", - "version>=" : "3.8.1" - }, { - "name" : "nlohmann-json", - "version>=" : "3.12.0" - }, { - "name" : "xxhash", - "version>=" : "0.8.3" - } ], - "builtin-baseline" : "2cbe970e2c987a55848ba3c6b1c1d285f220159e" -} \ No newline at end of file + "name": "observer-modules", + "version-string": "1.0.0", + "license": "LGPL-3.0-or-later", + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", + "dependencies": [ + { + "name": "catch2", + "version>=": "3.15.3" + }, + { + "name": "nlohmann-json", + "version>=": "3.12.0" + }, + { + "name": "xxhash", + "version>=": "0.8.3" + }, + { + "name": "zlib", + "version>=": "1.3.2" + } + ] +} From fc58853fb4da9a20d02b8926cf9febec3e258614 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 11:10:27 +1000 Subject: [PATCH 03/17] feat: replace build orchestration with local CAS DAG --- .gitignore | 5 + README.md | 1 + build.ps1 | 5 +- build/GRAPH.md | 5 + build/ObserverProject.props | 4 +- docs/README.md | 2 + docs/build-system.md | 12 + docs/current-status.md | 94 ++- docs/ix-build-adaptation.md | 194 ++++++ src/tests/unit/bounded_stream.cpp | 2 +- tools/build/core/__init__.py | 1 + tools/build/core/binary_audit.py | 131 ++++ tools/build/core/clang_dependencies.py | 203 ++++++ tools/build/core/clean.py | 141 ++++ tools/build/core/cpp_coverage.py | 68 ++ tools/build/core/doctor.py | 79 +++ tools/build/core/execute.py | 105 +++ tools/build/core/graph.py | 182 ++++++ tools/build/core/host.py | 107 +++ tools/build/core/leak.py | 234 +++++++ tools/build/core/node.py | 58 ++ tools/build/core/package.py | 221 +++++++ tools/build/core/paths.py | 132 ++++ tools/build/core/python_coverage.py | 56 ++ tools/build/core/quality_tools.py | 169 +++++ tools/build/core/recipe.py | 61 ++ tools/build/core/render.py | 55 ++ tools/build/core/runtime.py | 115 ++++ tools/build/core/sanitizer.py | 50 ++ tools/build/core/sarif.py | 183 ++++++ tools/build/core/sign.py | 83 +++ tools/build/core/source_tools.py | 69 ++ tools/build/core/store.py | 106 +++ tools/build/core/toolchain.py | 131 ++++ tools/build/core/windows_job.py | 44 ++ tools/build/core/windows_process.py | 122 ++++ tools/build/driver.py | 415 ++++++++++++ tools/build/graphs/__init__.py | 1 + tools/build/graphs/analysis.py | 456 +++++++++++++ tools/build/graphs/audit.py | 115 ++++ tools/build/graphs/common.py | 179 +++++ tools/build/graphs/coverage.py | 303 +++++++++ tools/build/graphs/fuzz.py | 272 ++++++++ tools/build/graphs/instrumented.py | 254 ++++++++ tools/build/graphs/leak.py | 132 ++++ tools/build/graphs/native.py | 288 +++++++++ tools/build/graphs/package.py | 194 ++++++ tools/build/graphs/python_coverage.py | 55 ++ tools/build/graphs/sanitizer.py | 281 ++++++++ tools/build/graphs/source.py | 196 ++++++ tools/build/main.py | 204 ++++++ tools/build/pyproject.toml | 27 + tools/build/templates/analysis.ps1 | 6 + tools/build/templates/argv.json | 3 + tools/build/templates/base.json | 6 + tools/build/templates/catch2-test.ps1 | 28 + tools/build/templates/clang-command.ps1 | 10 + tools/build/templates/clang-format.ps1 | 8 + tools/build/templates/clang-tidy.ps1 | 15 + tools/build/templates/contract.ps1 | 4 + .../build/templates/coverage-corpus-test.ps1 | 3 + tools/build/templates/coverage-merge.ps1 | 11 + tools/build/templates/coverage-report.ps1 | 24 + tools/build/templates/coverage-test.ps1 | 7 + tools/build/templates/cppcheck.ps1 | 41 ++ tools/build/templates/fuzz-build.ps1 | 11 + tools/build/templates/fuzz-common.ps1 | 36 ++ tools/build/templates/fuzz-gate.ps1 | 10 + tools/build/templates/fuzz-replay.ps1 | 10 + tools/build/templates/fuzz-run.ps1 | 21 + tools/build/templates/instrumented-build.ps1 | 8 + tools/build/templates/msbuild.ps1 | 18 + tools/build/templates/msvc-analyze.ps1 | 12 + tools/build/templates/native-build.ps1 | 10 + tools/build/templates/native-corpus-test.ps1 | 3 + tools/build/templates/native-test.ps1 | 1 + tools/build/templates/psscriptanalyzer.ps1 | 27 + tools/build/templates/pwsh.ps1 | 30 + tools/build/templates/sanitizer-test.ps1 | 8 + tools/build/templates/script.json | 8 + tools/build/templates/selected-compile.ps1 | 16 + tools/build/templates/source-dependencies.ps1 | 9 + tools/build/templates/vcpkg.ps1 | 14 + tools/build/tests/test_analysis_graph.py | 443 +++++++++++++ tools/build/tests/test_audit_graph.py | 323 +++++++++ tools/build/tests/test_binary_audit.py | 58 ++ tools/build/tests/test_clang_dependencies.py | 303 +++++++++ tools/build/tests/test_clean.py | 272 ++++++++ tools/build/tests/test_common.py | 97 +++ tools/build/tests/test_core_coverage.py | 380 +++++++++++ tools/build/tests/test_coverage_config.py | 33 + tools/build/tests/test_cpp_coverage_graph.py | 516 +++++++++++++++ tools/build/tests/test_doctor.py | 119 ++++ tools/build/tests/test_driver.py | 490 ++++++++++++++ tools/build/tests/test_execute.py | 257 ++++++++ tools/build/tests/test_fuzz_graph.py | 446 +++++++++++++ tools/build/tests/test_graph.py | 158 +++++ tools/build/tests/test_graph_main_coverage.py | 320 +++++++++ tools/build/tests/test_host.py | 77 +++ tools/build/tests/test_instrumented_graph.py | 564 ++++++++++++++++ tools/build/tests/test_leak_graph.py | 611 ++++++++++++++++++ tools/build/tests/test_main.py | 248 +++++++ tools/build/tests/test_native_graph.py | 398 ++++++++++++ tools/build/tests/test_node.py | 45 ++ tools/build/tests/test_package_graph.py | 498 ++++++++++++++ tools/build/tests/test_paths.py | 159 +++++ .../build/tests/test_python_coverage_graph.py | 225 +++++++ tools/build/tests/test_quality_tools.py | 289 +++++++++ tools/build/tests/test_recipe.py | 99 +++ tools/build/tests/test_render.py | 181 ++++++ tools/build/tests/test_runtime.py | 263 ++++++++ tools/build/tests/test_sanitizer_graph.py | 432 +++++++++++++ tools/build/tests/test_sarif.py | 306 +++++++++ tools/build/tests/test_sign.py | 137 ++++ tools/build/tests/test_source_graph.py | 274 ++++++++ tools/build/tests/test_source_tools.py | 114 ++++ tools/build/tests/test_store.py | 286 ++++++++ tools/build/tests/test_toolchain.py | 223 +++++++ tools/build/tests/test_vcpkg_template.py | 55 ++ tools/build/tests/test_windows_job.py | 163 +++++ tools/build/tests/test_windows_process.py | 400 ++++++++++++ tools/build/uv.lock | 146 +++++ 122 files changed, 17393 insertions(+), 35 deletions(-) create mode 100644 docs/ix-build-adaptation.md create mode 100644 tools/build/core/__init__.py create mode 100644 tools/build/core/binary_audit.py create mode 100644 tools/build/core/clang_dependencies.py create mode 100644 tools/build/core/clean.py create mode 100644 tools/build/core/cpp_coverage.py create mode 100644 tools/build/core/doctor.py create mode 100644 tools/build/core/execute.py create mode 100644 tools/build/core/graph.py create mode 100644 tools/build/core/host.py create mode 100644 tools/build/core/leak.py create mode 100644 tools/build/core/node.py create mode 100644 tools/build/core/package.py create mode 100644 tools/build/core/paths.py create mode 100644 tools/build/core/python_coverage.py create mode 100644 tools/build/core/quality_tools.py create mode 100644 tools/build/core/recipe.py create mode 100644 tools/build/core/render.py create mode 100644 tools/build/core/runtime.py create mode 100644 tools/build/core/sanitizer.py create mode 100644 tools/build/core/sarif.py create mode 100644 tools/build/core/sign.py create mode 100644 tools/build/core/source_tools.py create mode 100644 tools/build/core/store.py create mode 100644 tools/build/core/toolchain.py create mode 100644 tools/build/core/windows_job.py create mode 100644 tools/build/core/windows_process.py create mode 100644 tools/build/driver.py create mode 100644 tools/build/graphs/__init__.py create mode 100644 tools/build/graphs/analysis.py create mode 100644 tools/build/graphs/audit.py create mode 100644 tools/build/graphs/common.py create mode 100644 tools/build/graphs/coverage.py create mode 100644 tools/build/graphs/fuzz.py create mode 100644 tools/build/graphs/instrumented.py create mode 100644 tools/build/graphs/leak.py create mode 100644 tools/build/graphs/native.py create mode 100644 tools/build/graphs/package.py create mode 100644 tools/build/graphs/python_coverage.py create mode 100644 tools/build/graphs/sanitizer.py create mode 100644 tools/build/graphs/source.py create mode 100644 tools/build/main.py create mode 100644 tools/build/pyproject.toml create mode 100644 tools/build/templates/analysis.ps1 create mode 100644 tools/build/templates/argv.json create mode 100644 tools/build/templates/base.json create mode 100644 tools/build/templates/catch2-test.ps1 create mode 100644 tools/build/templates/clang-command.ps1 create mode 100644 tools/build/templates/clang-format.ps1 create mode 100644 tools/build/templates/clang-tidy.ps1 create mode 100644 tools/build/templates/contract.ps1 create mode 100644 tools/build/templates/coverage-corpus-test.ps1 create mode 100644 tools/build/templates/coverage-merge.ps1 create mode 100644 tools/build/templates/coverage-report.ps1 create mode 100644 tools/build/templates/coverage-test.ps1 create mode 100644 tools/build/templates/cppcheck.ps1 create mode 100644 tools/build/templates/fuzz-build.ps1 create mode 100644 tools/build/templates/fuzz-common.ps1 create mode 100644 tools/build/templates/fuzz-gate.ps1 create mode 100644 tools/build/templates/fuzz-replay.ps1 create mode 100644 tools/build/templates/fuzz-run.ps1 create mode 100644 tools/build/templates/instrumented-build.ps1 create mode 100644 tools/build/templates/msbuild.ps1 create mode 100644 tools/build/templates/msvc-analyze.ps1 create mode 100644 tools/build/templates/native-build.ps1 create mode 100644 tools/build/templates/native-corpus-test.ps1 create mode 100644 tools/build/templates/native-test.ps1 create mode 100644 tools/build/templates/psscriptanalyzer.ps1 create mode 100644 tools/build/templates/pwsh.ps1 create mode 100644 tools/build/templates/sanitizer-test.ps1 create mode 100644 tools/build/templates/script.json create mode 100644 tools/build/templates/selected-compile.ps1 create mode 100644 tools/build/templates/source-dependencies.ps1 create mode 100644 tools/build/templates/vcpkg.ps1 create mode 100644 tools/build/tests/test_analysis_graph.py create mode 100644 tools/build/tests/test_audit_graph.py create mode 100644 tools/build/tests/test_binary_audit.py create mode 100644 tools/build/tests/test_clang_dependencies.py create mode 100644 tools/build/tests/test_clean.py create mode 100644 tools/build/tests/test_common.py create mode 100644 tools/build/tests/test_core_coverage.py create mode 100644 tools/build/tests/test_coverage_config.py create mode 100644 tools/build/tests/test_cpp_coverage_graph.py create mode 100644 tools/build/tests/test_doctor.py create mode 100644 tools/build/tests/test_driver.py create mode 100644 tools/build/tests/test_execute.py create mode 100644 tools/build/tests/test_fuzz_graph.py create mode 100644 tools/build/tests/test_graph.py create mode 100644 tools/build/tests/test_graph_main_coverage.py create mode 100644 tools/build/tests/test_host.py create mode 100644 tools/build/tests/test_instrumented_graph.py create mode 100644 tools/build/tests/test_leak_graph.py create mode 100644 tools/build/tests/test_main.py create mode 100644 tools/build/tests/test_native_graph.py create mode 100644 tools/build/tests/test_node.py create mode 100644 tools/build/tests/test_package_graph.py create mode 100644 tools/build/tests/test_paths.py create mode 100644 tools/build/tests/test_python_coverage_graph.py create mode 100644 tools/build/tests/test_quality_tools.py create mode 100644 tools/build/tests/test_recipe.py create mode 100644 tools/build/tests/test_render.py create mode 100644 tools/build/tests/test_runtime.py create mode 100644 tools/build/tests/test_sanitizer_graph.py create mode 100644 tools/build/tests/test_sarif.py create mode 100644 tools/build/tests/test_sign.py create mode 100644 tools/build/tests/test_source_graph.py create mode 100644 tools/build/tests/test_source_tools.py create mode 100644 tools/build/tests/test_store.py create mode 100644 tools/build/tests/test_toolchain.py create mode 100644 tools/build/tests/test_vcpkg_template.py create mode 100644 tools/build/tests/test_windows_job.py create mode 100644 tools/build/tests/test_windows_process.py create mode 100644 tools/build/uv.lock diff --git a/.gitignore b/.gitignore index 198203a..03bf99b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ /.artifacts +/out/ +/.coverage* +/tools/build/.venv/ +/tools/build/.uv-cache/ +/tools/build/.coverage* /.idea/ diff --git a/README.md b/README.md index 58b86a7..e4ef561 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ specific files as needed without having to unpack the entire archive. | [birkenfeld/serde-pickle](https://github.com/birkenfeld/serde-pickle) | [MIT](licenses/serde-pickle.txt) | | [Shizmob/rpatool](https://github.com/Shizmob/rpatool) | [WTFPL](licenses/rpatool.txt) | | [zanzapak](https://aluigi.altervista.org/papers.htm#others-file) | [GPL-3.0](licenses/zanzapak.txt) | +| [pg83/ix](https://github.com/pg83/ix/tree/66726a904152246fbef8b27e26e878840f6d7fb7) | [MIT](licenses/IX.txt) | ## Building from Source diff --git a/build.ps1 b/build.ps1 index acced7b..e69561a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,4 +3,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -& (Join-Path $PSScriptRoot 'build\build.ps1') @args +$buildProject = Join-Path $PSScriptRoot 'tools\build' +$env:UV_CACHE_DIR = Join-Path $buildProject '.uv-cache' +& uv run --project $buildProject --frozen --no-sync python (Join-Path $buildProject 'main.py') @args +exit $LASTEXITCODE diff --git a/build/GRAPH.md b/build/GRAPH.md index 1527703..043e568 100644 --- a/build/GRAPH.md +++ b/build/GRAPH.md @@ -1,5 +1,10 @@ # Experimental build graph driver +> **Historical baseline:** the owner approved the replacement design in +> [`docs/ix-build-adaptation.md`](../docs/ix-build-adaptation.md) on 2026-08-01. +> This coarse driver and the later fine-graph spikes remain only as benchmark/oracle +> code until the new implementation proves parity. Do not extend them into production. + `graph_driver.py` is a stdlib-only outer DAG experiment. It schedules existing `build.ps1` commands; it does not replace their MSBuild, vcpkg, analysis, test, or packaging implementation. Nothing calls the driver from the default build entrypoint. diff --git a/build/ObserverProject.props b/build/ObserverProject.props index 620f5c1..7b63c68 100644 --- a/build/ObserverProject.props +++ b/build/ObserverProject.props @@ -52,7 +52,7 @@ Sync MultiThreaded MultiThreadedDebug - ProgramDatabase + OldStyle EnableFastChecks Default false @@ -63,6 +63,8 @@ $(RepositoryRoot)src;%(AdditionalIncludeDirectories) NOMINMAX;%(PreprocessorDefinitions) /utf-8 /Zc:__cplusplus %(AdditionalOptions) + /sourceDependencies "$(ObserverSourceDependenciesPath)" %(AdditionalOptions) + /clang:-MJ"$(ObserverClangCommandPath)" %(AdditionalOptions) /clang:-fprofile-instr-generate /clang:-fcoverage-mapping %(AdditionalOptions) /fsanitize=address %(AdditionalOptions) /clang:-fsanitize=undefined /clang:-fno-sanitize-recover=all %(AdditionalOptions) diff --git a/docs/README.md b/docs/README.md index 30550d7..5c770e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ continuation order for a new chat. - [Current code deep dive](code-deep-dive.md) — architecture, format implementations, defects, and technical risks found during the static review. - [Build system and engineering workflow](build-system.md) — MSBuild, toolchain, tests, analysis, coverage, fuzzing, and packaging. +- [IX-derived local build DAG](ix-build-adaptation.md) — approved Python/Jinja orchestration, MD5 CAS, Windows + execution safety, and WSL2 extension plan. - [High-assurance software methodology](critical-software-methodology.md) — TDD, architecture and ABI boundaries, ownership, parser safety, and verification policy. - [Autonomous build-system work log](autonomous-work-log.md) — temporary decisions, doubts, and verification evidence for review. - [GARbro module plan](garbro.md) diff --git a/docs/build-system.md b/docs/build-system.md index 1c37f47..6918c00 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -61,6 +61,18 @@ coverage, binary auditing, and packaging. Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every format target. +The IX-derived replacement is intentionally separate until it reaches complete command parity. Its current 533-node +analysis graph runs MSVC `/analyze` and clang-tidy independently per supported project/TU/architecture, normalizes each +report independently, and then executes deterministic per-architecture SARIF merge and semantic clean gates. From the +repository root, run the pinned environment without syncing or downloading: + +```powershell +uv run --project tools/build --frozen --no-sync python tools/build/main.py analysis-slice --repository . --arch all +``` + +Results are printed as exact paths below `out/cas`; a second identical run is served from touch-marker cache entries. +This pilot does not replace any documented `build.ps1` command yet. + `verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A diff --git a/docs/current-status.md b/docs/current-status.md index f45acf0..653f99c 100644 --- a/docs/current-status.md +++ b/docs/current-status.md @@ -8,11 +8,13 @@ as authoritative; older counts in `autonomous-work-log.md` are historical snapsh - Repository: `C:\Users\Roma\Dev\ObserverModules` - Branch: `codex/msbuild-toolchain` -- The worktree intentionally contains the complete migration and is not yet committed or pushed. +- Checkpoint commit `294b584` preserves the complete MSBuild migration and DAG experiments before the approved + IX-derived replacement work. The branch has not been pushed. - `CLAUDE.md` has been replaced by repository-level `AGENTS.md`. - The old CMake/IDE entry points are being removed. CMake remains acceptable only inside vcpkg ports. - Do not create a background Goal for this work. In the previous chat Goal cards repeatedly became unavailable. -- Do not install or update software. Ask the owner when another tool is required. +- Do not install or update global/system tools. Project-local Python dependencies may be added and locked with `uv`; + report missing external developer tools instead of installing them. - Keep verbose command output in `.artifacts/*.log` and report only concise results in chat; verbose dynamic-check output repeatedly triggered a Codex UI display filter, although commands and filesystem changes continued normally. @@ -22,8 +24,9 @@ as authoritative; older counts in `autonomous-work-log.md` are historical snapsh - Static CRT (`/MT`) and static third-party dependencies; release packages must not require the VC redistributable or adjacent dependency DLLs. - The console entry point is `build.ps1`/`build.cmd`, with direct MSBuild project files under `build/`. -- `Release` is the shippable optimized-with-symbols configuration: `/O2`, `/GL`, `/LTCG`, `/MT`, `/Zi`, and a full - linker PDB. There is intentionally no separate `RelWithDebInfo` configuration. +- `Release` is the shippable optimized-with-symbols configuration: `/O2`, `/GL`, `/LTCG`, `/MT`, compiler-embedded + `/Z7` symbols, and a full linker PDB. `/Z7` removes the shared compiler-PDB service from parallel isolated build + leaves; the linker still emits the distributable PDB. There is intentionally no separate `RelWithDebInfo`. - TDD, 100% production line and branch coverage, mutation testing, deterministic tests, format-aware dynamic testing, leak checks, binary inspection, and hermetic CI. - Clean architecture and strict C/C++ boundaries. Application and test code must not call a C API directly; a C API is @@ -52,6 +55,41 @@ as authoritative; older counts in `autonomous-work-log.md` are historical snapsh C++ APIs, so a later decompressor replacement is local. - Ren'Py, RPG Maker, and Zanzarah share `observer::io::bounded_stream` for checked positioning and exact reads. +### IX-derived replacement pilot + +- `tools/build` now contains the working Python 3.14/Jinja replacement core: canonical MD5 identities, demand DAG + execution, named pools, native interprocess locks through `filelock`, repository-local `out/cas`, confined work + paths, and Windows process-tree cancellation through `psutil` plus `pywin32` Job Objects. +- The project environment exact-pins `coverage==7.15.2`; its blocking gate measures all first-party `core`, `graphs`, + and CLI code with 100% line and branch coverage and no production exclusions. +- The production analysis graph now covers every supported first-party project/TU occurrence on x86, x64, and ARM64. + It contains 533 nodes: 262 independent raw analyzer leaves, 262 independent normalizers, three shared restore leaves, + and per-architecture deterministic merge/semantic gates. The x64-only leak probe is deliberately absent from the + cross-architecture graphs. Every analyzer has its own object root. +- Raw TU identities currently use a deliberately restricted literal-include closure. Computed includes, + `#include_next`, and `__has_include` are rejected instead of being under-signed; the first-party path namespace is + also signed. The planned generic form is a two-phase compiler-derived resolver using MSVC `/sourceDependencies` and + `clang-scan-deps`, so this bootstrap scanner is not presented as a general C++ preprocessor. +- Official `vswhere.exe` and `VsDevCmd.bat` discovery fingerprints MSBuild 17.14.51.32402, MSVC 14.44.35207, + clang-tidy 19.1.5, Windows SDK 10.0.26100.0, and the resolved vcpkg root. Discovery does not mutate the parent + environment or install tools. The signed developer-environment delta deliberately excludes inherited/transport + `PATH`; runtime overlays append the host path case-insensitively, so CAS identities are stable across launch modes. +- The native graph splits project/configuration/architecture builds and Catch2 shards into isolated CAS leaves. + x64 Debug and Release matrices have run successfully with four concurrent MSBuild project leaves. Compiler debug + data uses `/Z7`, avoiding cross-Job `mspdbsrv` RPC failures while the linker still emits a full PDB. +- Fine-grained fuzz and release-binary audit graphs are implemented. Each fuzz target has independent build, corpus + replay, and nonce-signed timed execution; each module audit has three independent dumpbin leaves, a PE policy gate, + a BinSkim leaf, and a separate SARIF policy gate. +- The earlier 1,248-line pilot count is obsolete now that real graph families have replaced projections. Re-measure + production LOC after parity and the required structural reduction; compare the final replacement, not an incomplete + slice, with the 2,321-line old PowerShell production surface. +- The root `build.ps1` contract has deliberately not switched. Run the pilot from the repository root with: + + ```powershell + uv run --project tools/build --frozen --no-sync python tools/build/main.py analysis-slice --repository . --arch all + uv run --project tools/build --frozen --no-sync python tools/build/main.py native --repository . --arch x64 --config Debug + ``` + ## Latest verified evidence All commands below completed successfully after the latest Pickle regression fix. @@ -66,6 +104,15 @@ All commands below completed successfully after the latest Pickle regression fix | Short Ren'Py format run | 345,037 executions in 15 seconds | | Short RPG Maker format run | 219,383 executions in 15 seconds | | Short Zanzarah format run | 418,998 executions in 15 seconds | +| Replacement Python suite before leak/package completion | 143 tests; 1305 statements and 380 branches, all 100% | +| Complete 191-node x64 graph, first cold run | 134 seconds; one real C26498 finding reached only the semantic gate | +| x64 incremental after the one-line `constexpr` fix | 17.7 seconds | +| Complete 191-node x64 graph, warm | 2.91 seconds | +| Cold addition of x86 and ARM64 | about 200 seconds for 168 new raw analyzer leaves at 12-way concurrency | +| Complete 533-node three-architecture graph, warm | 4.38 seconds | +| x64 Debug native graph, four project leaves and four test shards | cold run green; no C1090 after `/Z7` | +| x64 Release native graph, including leak-probe build | cold run green with `/MT`, `/GL`, `/LTCG`, and linker PDB | +| x64 Debug native graph, warm | 1.66 seconds | The short all-format command returned exit code 0. Detailed local evidence is in: @@ -81,34 +128,17 @@ and both 100% coverage and the all-format short run passed again. ## Required next work, in order -1. Run `source-checks` and fix clang-format, Cppcheck, and PSScriptAnalyzer findings introduced by the latest changes. - Do not weaken rules to make the gate green. -2. Check in a minimized seed for the new Pickle regression, replay every checked-in seed, then define a longer - all-format CI schedule. Seed the four targets with representative examples derived from the external corpus without - committing the multi-gigabyte corpus. -3. Rework `test-leaks` and `leak-probe.vcxproj` to test the exact optimized x64 `Release` (`/MT`) DLLs. The current CI - leak job is mandatory but the script still hard-codes a Debug probe. Expand cases beyond small happy paths to cover - parse failure, cancellation, read/write failure, repeated DLL lifecycle, and large/sparse metadata workloads. -4. Add package smoke verification: unpack each produced ZIP, assert its exact contents, load the DLL from the unpacked - package, and exercise the Observer API. Run packaging for x86/x64/ARM64 to prove the new one-PDB-ZIP-per-architecture - layout. Ensure each module package contains only its own applicable third-party documents. -5. Make `verify` a truthful aggregate gate. It currently omits coverage, sanitizers, leak checks, all-format runs, and - package smoke. Split build-only ARM64 verification from executable tests when the host cannot run ARM64 binaries; - `verify -Arch all` must not fail merely because an x64 host cannot execute ARM64. -6. Run the clean build/test matrix: MSVC x86 and x64 Debug/Release tests; ARM64 Debug/Release builds locally and tests on - the native GitHub runner. Then run ASan x86/x64 and clang-cl UBSan x64 after the latest parser changes. -7. Run `/analyze` and clang-tidy for modules, tests, format targets, and the leak probe on all supported architectures. - Currently the aggregate analysis graph omits the format targets and leak probe. -8. Finish GitHub report integration. MSVC analysis, Cppcheck, CodeQL, and BinSkim should upload SARIF. clang-tidy still - needs a reliable SARIF conversion/upload path. Preserve mandatory job semantics even when reports use - `continue-on-error` for artifact collection. -9. Run `audit-binaries` for x86/x64/ARM64 and verify the exact Release DLLs with dumpbin and BinSkim: `/MT`, expected - architecture and mitigations, no debug CRT, no unexpected imports, no adjacent dependency DLLs. BA2027 about absent - SourceLink remains an explicit owner decision, not a silently suppressed result. -10. Implement mutation testing. The leading approach is to extract a portable parser core and run Mull in Linux CI; - no reached non-equivalent surviving mutant is acceptable. Do not install Mull locally without owner approval. -11. Update the permanent docs with final evidence, inspect the complete diff, commit on `codex/msbuild-toolchain`, push, - and report any CI-only assumptions that still require the first GitHub run. +1. Complete the in-progress split of source checks, builds/tests, fuzz targets, leak scenarios/modes, audit work, + packaging, and remaining gates + into the smallest safe nodes with measured pool capacities. +2. Replace the restricted include-closure bootstrap with the recorded compiler-derived two-phase resolver before the + CAS is treated as generic for arbitrary future C++ include forms. +3. Preserve the current `build.ps1` implementation and old DAG experiments until the replacement has full result + parity and measured cold/warm local evidence. Only then switch the entry point. After the switch is verified and + checkpointed, delete the superseded PowerShell orchestration, experimental DAGs, their legacy-only tests, and stale + build documentation; retain the MSBuild projects/props/targets because they remain the native backend. +4. After the portable parser core is established, add the documented WSL2/Linux local backend for Mull and the Linux + sanitizer surface. CI repeats locally runnable commands; it is not the only place those gates may run. After the mandatory gates above, the authorized stretch work is IWYU integration and extraction of a portable parser core statically linked into the existing module DLLs. This must not add a runtime DLL or change the public Observer ABI. diff --git a/docs/ix-build-adaptation.md b/docs/ix-build-adaptation.md new file mode 100644 index 0000000..13a7916 --- /dev/null +++ b/docs/ix-build-adaptation.md @@ -0,0 +1,194 @@ +# IX-derived local build DAG + +This document records the owner-approved target for replacing the large PowerShell +orchestration layer. The decision was finalized on 2026-08-01 after studying +[`pg83/ix`](https://github.com/pg83/ix) at revision +`66726a904152246fbef8b27e26e878840f6d7fb7`. Implementation follows strict TDD. + +## Scope + +MSBuild remains the native Windows compile/link backend. The new layer only renders +recipes, signs their complete inputs, constructs a fine-grained DAG, executes ready +nodes concurrently, and caches successful outputs. Replacing MSBuild with direct +`cl.exe`/`link.exe` orchestration is out of scope. + +The existing `build.ps1` command contract remains authoritative until the new system +has result parity for the complete local verification matrix. The coarse and fine DAG +experiments under `build/` are frozen fallback/oracle code, not the foundation of the +new implementation. + +Prefer a mature, focused open-source library or the Python standard library over +first-party infrastructure whenever it provides the required contract. Dependencies +are pinned exactly with `uv`; custom code is reserved for ObserverModules-specific +policy or guarantees unavailable from an existing component. + +## Kept from IX + +- Jinja recipe inheritance; +- `in_dir`/`out_dir` node semantics; +- content-addressed immutable outputs; +- MD5 identities and touch-marker cache hits; +- demand-driven `asyncio` graph traversal; +- exactly one named resource pool per node; +- dependency UID propagation into dependent identities. + +## Windows adaptations + +- Jinja renders PowerShell recipes instead of POSIX shell recipes. MSBuild, vcpkg, + clang-tidy, fuzzers, UMDH, audit tools, and packaging remain ordinary recipe tools. +- `StrictUndefined` makes a missing template value fail before signing or execution. +- PowerShell values use a dedicated single-quote filter; recipes do not interpolate + unquoted paths or user-controlled values. +- Windows Job Objects replace POSIX process groups so cancellation terminates the + complete `pwsh -> MSBuild -> cl/link` process tree. +- A per-UID interprocess lock prevents concurrent local processes from publishing the + same CAS entry. +- Every mutable path is confined below the repository output root. Escapes and existing + reparse-point components or leaves are rejected before mutation. +- The Linux isolation model is not claimed on Windows. Path confinement and Job Objects + provide narrower, explicit guarantees rather than pretending to reproduce `unshare`. + +The local concurrency model assumes cooperating build processes using the UID lock. It +does not claim protection from another same-user process maliciously replacing path +components during a filesystem operation; that stronger boundary would require +handle-relative Win32 filesystem operations or an OS sandbox. The driver therefore +validates confinement and existing reparse points before mutation and uses exclusive +creation, without duplicating speculative post-operation TOCTOU checks. + +MD5 is retained deliberately for compatibility with the IX content-identity model. It +identifies local deterministic build inputs; it is not presented as a cryptographic +integrity boundary for untrusted remote artifacts. + +## Repository layout + +```text +ObserverModules/ +|-- build.ps1 +|-- tools/ +| `-- build/ +| |-- pyproject.toml +| |-- uv.lock +| |-- main.py +| |-- core/ +| | |-- execute.py, graph.py, recipe.py, render.py, sign.py +| | |-- paths.py, runtime.py, store.py +| | |-- sarif.py, toolchain.py +| | `-- windows_job.py, windows_process.py +| |-- graphs/ +| | `-- analysis.py +| |-- templates/ +| | |-- base.json, script.json, argv.json +| | |-- pwsh.ps1, msbuild.ps1, analysis.ps1 +| | `-- msvc-analyze.ps1, clang-tidy.ps1, vcpkg.ps1 +| `-- tests/ +|-- out/ +| |-- cas/ +| | `-- -/ +| | |-- out/ +| | |-- log.txt +| | `-- touch +| `-- work/ +| |-- / +| `-- .locks/ +|-- src/ +`-- docs/ +``` + +Templates remain flat while the set is small. A maintained leaf recipe should normally +be 2-15 lines because setup, error handling, logging, and common argv live in inherited +base templates. Generated scripts may be longer; they are disposable execution data. +Subdirectories are introduced only when real template families require them. + +`out/` contains only two top-level entries and is ignored by Git: + +- `out/cas` contains UID-addressed outputs, their successful-task log, and a zero-byte + `touch` marker; +- `out/work` contains in-flight compiler intermediates, failed-task evidence, + quarantined incomplete entries, and internal lock files. A successful node removes + its exact scratch directory immediately; successful run directories therefore do + not accumulate beside the CAS. + +There is no speculative top-level `results` or `trash` directory. Commands print exact +result paths. An incomplete CAS entry has no marker, is never a cache hit, and is moved +under the locked work area or safely removed before rebuilding. + +## Rendering and identity + +A logical node selects a recipe and passes explicit validated values such as project, +source, architecture, configuration, tool paths, `in_dir`, and `out_dir`. Jinja expands +the complete inheritance chain before any process starts. + +As in IX, the rendered descriptor contains a literal `exec` argv array and signed +`data`. PowerShell uses a constant `-Command` wrapper that constructs a script block +from the exact UTF-8 recipe bytes received on stdin. Parser and terminating errors +therefore produce a nonzero process result; stdin remains recipe transport, not +interactive input. This avoids a temporary script pathname in the identity and +therefore avoids a UID/path cycle. Concrete CAS/work paths are derived after signing +and supplied through validated process environment values. + +The canonical MD5 input includes: + +- rendered recipe bytes; +- literal argv and relevant environment/configuration values; +- logical input paths and exact input bytes; +- dependency UIDs; +- toolchain and target-platform identity; +- the recipe/executor schema identity. + +Mapping order cannot affect the digest. A change to any signed field must change the +UID. A cache hit requires both the expected CAS entry and its touch marker. +The readable node-name suffix in `-` is diagnostic only, as in IX's +`-` layout; the MD5 is the content identity. + +## Graph and failure model + +Nodes are split at the smallest safe independently executable unit. The production +verify graph includes separate project/configuration builds, test shards, MSVC analysis +and clang-tidy translation units, SARIF normalization and fan-in, fuzz targets, leak +scenario/mode work, audit tool/module work, and package/smoke units. + +Every node names one pool. Initial pool classes are `full`, `slot`, `fuzz`, `umdh`, +`binskim`, and `misc`; measured resource behavior determines capacities. Pools constrain +actual contention instead of imposing phase-wide ordering edges. + +Infrastructure failure or cancellation produces no touch marker. Diagnostic tools may +write their raw report and an explicit status result even when findings exist; a later +semantic gate consumes those reports and fails the build after evidence is preserved. + +## WSL2 extension + +The Python graph/signing core is platform-neutral. A future WSL2 workflow adds a common +`sh.sh` base and short `.sh` counterparts for portable actions such as clang-tidy, +fuzzing, sanitizers, and Mull. PowerShell is not translated automatically into shell. + +The Linux driver runs once inside WSL2 and executes the DAG natively with POSIX process +groups. Windows must not launch one `wsl.exe` process per node. Platform, shell recipe, +and toolchain identity are signed, so Windows and Linux outputs cannot alias. The WSL2 +workflow is a local quality backend for the portable parser core; shipping DLLs remain +Windows/MSVC artifacts. + +## TDD migration order + +Steps 1-3 are proven and the analysis portion of step 4 is complete for every supported +project/TU/architecture occurrence. The same per-leaf shape remains +`/analyze || clang-tidy -> normalize || normalize -> merge -> semantic gate`; the complete +533-node three-architecture graph is a 4.38-second warm cache hit. Expansion remains +data-driven and does not switch the root entry point. + +1. Prove Jinja inheritance, `StrictUndefined`, PowerShell quoting, and canonical MD5. +2. Prove graph validation, demand execution, pool behavior, touch cache hits, confined + paths, interprocess locks, and Job Object cancellation with tool-free unit tests. +3. Implement one real x64 vertical slice for `src/modules/renpy/pickle.cpp`: + MSVC `/analyze` and clang-tidy in parallel, deterministic SARIF normalization, and a + final semantic gate. A second identical run must be all cache hits; changing an input + must invalidate only its consumers. +4. Expand to every first-party translation unit, then split fuzzing, leaks, audits, + packaging, and the remaining verification stages. The TU expansion is complete; the + other graph families are in progress. +5. Establish full result parity and benchmark cold/warm local runs against the current + `build.ps1 verify` baseline. +6. Switch the root entry point only after parity, then remove superseded graph and + PowerShell code. The checkpoint commit keeps that cleanup recoverable. + +No tool is installed or updated automatically. `uv` owns the pinned Python/Jinja +environment once the already-approved local prerequisite is available. diff --git a/src/tests/unit/bounded_stream.cpp b/src/tests/unit/bounded_stream.cpp index 9c46447..1f29e19 100644 --- a/src/tests/unit/bounded_stream.cpp +++ b/src/tests/unit/bounded_stream.cpp @@ -214,7 +214,7 @@ TEST_CASE("bounded stream: normalizes seek and read failures") { std::istringstream source("abc"); observer::io::bounded_stream input(source); - const auto impossible_size = static_cast(std::numeric_limits::max()) + 1; + constexpr auto impossible_size = static_cast(std::numeric_limits::max()) + 1; REQUIRE_THROWS_AS(input.read_exact(nullptr, impossible_size), observer::io::read_error); } } diff --git a/tools/build/core/__init__.py b/tools/build/core/__init__.py new file mode 100644 index 0000000..db0d63b --- /dev/null +++ b/tools/build/core/__init__.py @@ -0,0 +1 @@ +"""Core primitives for the local ObserverModules build graph.""" diff --git a/tools/build/core/binary_audit.py b/tools/build/core/binary_audit.py new file mode 100644 index 0000000..e51a011 --- /dev/null +++ b/tools/build/core/binary_audit.py @@ -0,0 +1,131 @@ +"""Release PE and BinSkim policy shared by fine-grained audit leaves.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +from collections.abc import Sequence + + +class AuditError(RuntimeError): + pass + + +_MACHINES = { + "x86": r"14C machine \(x86\)", + "x64": r"8664 machine \(x64\)", + "arm64": r"AA64 machine \(ARM64\)", +} +_ALLOWED_DLLS = { + "advapi32.dll", + "bcrypt.dll", + "kernel32.dll", + "ntdll.dll", + "ole32.dll", + "oleaut32.dll", + "shell32.dll", + "shlwapi.dll", + "user32.dll", +} +_FORBIDDEN_DLL = re.compile( + r"^(?:vcruntime|msvcp|ucrtbase|api-ms-win-crt-|ext-ms-win-crt-|zlib|zstd|xxhash|clang_rt\.).*\.dll$", + re.IGNORECASE, +) + + +def require_release_pe( + architecture: str, headers: str, dependents: str, exports: str +) -> None: + try: + machine = _MACHINES[architecture] + except KeyError as error: + raise AuditError(f"unsupported machine architecture: {architecture}") from error + if re.search(machine, headers) is None: + raise AuditError(f"wrong PE machine for {architecture}") + + dependencies = { + match.group(1) + for line in dependents.splitlines() + if (match := re.fullmatch(r"\s+([A-Za-z0-9._-]+\.dll)\s*", line)) + } + unexpected = sorted( + dependency + for dependency in dependencies + if _FORBIDDEN_DLL.match(dependency) + or ( + dependency.casefold() not in _ALLOWED_DLLS + and re.match(r"^(?:api|ext)-ms-win-.*\.dll$", dependency, re.IGNORECASE) is None + ) + ) + if unexpected: + raise AuditError("unexpected DLL dependencies: " + ", ".join(unexpected)) + + actual_exports = { + match.group(1) + for line in exports.splitlines() + if (match := re.match(r"^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)", line)) + } + expected_exports = {"LoadSubModule", "UnloadSubModule"} + if actual_exports != expected_exports: + raise AuditError("unexpected exports: " + ", ".join(sorted(actual_exports))) + + +def require_clean_binskim(document: dict[str, object]) -> None: + findings = [] + for run in document.get("runs", []): + rules = { + rule["id"]: rule.get("defaultConfiguration", {}).get("level", "warning") + for rule in run.get("tool", {}).get("driver", {}).get("rules", []) + } + for result in run.get("results", []): + rule = result.get("ruleId", "") + level = result.get("level", rules.get(rule, "warning")) + if level in {"warning", "error"} and not (level == "warning" and rule == "BA2027"): + findings.append(f"{level}:{rule}") + if findings: + raise AuditError("BinSkim unapproved findings: " + ", ".join(findings)) + + +def _run_binskim(tool: Path, binary: Path) -> None: + raw_output = os.environ.get("OBSERVER_OUT_DIR") + if not raw_output or not (output_root := Path(raw_output)).is_dir(): + raise AuditError("OBSERVER_OUT_DIR must be an existing directory") + report = output_root / "binskim.sarif" + argv = [ + str(tool), "analyze", str(binary), "--level", "Error;Warning", "--kind", "Fail", + "--local-symbol-directories", str(binary.parent), "--output", str(report), + "--log", "ForceOverwrite", "--quiet", "--disable-telemetry", + ] + subprocess.run(argv, check=True) + if not report.is_file(): + raise AuditError("BinSkim did not produce binskim.sarif") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + pe = commands.add_parser("pe") + for argument in ("architecture", "headers", "dependents", "exports"): + pe.add_argument(argument) + report = commands.add_parser("binskim") + report.add_argument("report") + run = commands.add_parser("run-binskim") + run.add_argument("tool") + run.add_argument("binary") + args = parser.parse_args(argv) + if args.command == "pe": + texts = [Path(getattr(args, name)).read_text(encoding="utf-8-sig") for name in ("headers", "dependents", "exports")] + require_release_pe(args.architecture, *texts) + elif args.command == "binskim": + require_clean_binskim(json.loads(Path(args.report).read_text(encoding="utf-8-sig"))) + else: + _run_binskim(Path(args.tool), Path(args.binary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/clang_dependencies.py b/tools/build/core/clang_dependencies.py new file mode 100644 index 0000000..28579af --- /dev/null +++ b/tools/build/core/clang_dependencies.py @@ -0,0 +1,203 @@ +"""Resolve clang-cl translation-unit inputs through ``clang-scan-deps``.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import json +import os +from pathlib import Path +import subprocess +from typing import Any + + +class ClangDependencyError(RuntimeError): + pass + + +def _file(path: Path, label: str) -> Path: + try: + resolved = path.resolve(strict=True) + except OSError as error: + raise ClangDependencyError(f"invalid clang dependency {label}: {path}") from error + if not resolved.is_file(): + raise ClangDependencyError(f"invalid clang dependency {label}: {path}") + return resolved + + +def load_compile_command(command: Path, source: Path, compiler: Path) -> dict[str, Any]: + """Load one clang ``-MJ`` fragment and remove its capture-only argument.""" + + expected_source, expected_compiler = _file(source, "source"), _file(compiler, "compiler") + try: + content = command.read_text(encoding="utf-8-sig").rstrip() + document = json.loads(content[:-1] if content.endswith(",") else content) + except (OSError, json.JSONDecodeError) as error: + raise ClangDependencyError(f"invalid clang compilation-command JSON: {command}") from error + if not isinstance(document, dict): + raise ClangDependencyError("invalid clang compilation-command JSON object") + try: + directory = Path(document["directory"]).resolve(strict=True) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang compilation-command directory") from error + if not directory.is_dir(): + raise ClangDependencyError("invalid clang compilation-command directory") + try: + reported_source = Path(document["file"]) + reported_source = (directory / reported_source).resolve(strict=True) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang compilation-command source") from error + if reported_source != expected_source: + raise ClangDependencyError("clang compilation-command source mismatch") + arguments = document.get("arguments") + if not isinstance(arguments, list) or not arguments or not all( + isinstance(item, str) for item in arguments + ): + raise ClangDependencyError("invalid clang compilation-command arguments") + try: + reported_compiler = Path(arguments[0]).resolve(strict=True) + except OSError as error: + raise ClangDependencyError("invalid clang compilation-command compiler") from error + if reported_compiler != expected_compiler: + raise ClangDependencyError("clang compilation-command compiler mismatch") + return { + **document, + "directory": str(directory), + "file": str(expected_source), + "arguments": [ + str(expected_compiler), + *(item for item in arguments[1:] if not item.casefold().startswith("/clang:-mj")), + ], + } + + +def dependency_manifest(source: Path, document: object) -> dict[str, object]: + """Convert experimental-full scanner JSON to the existing canonical manifest.""" + + expected_source = _file(source, "source") + try: + if not isinstance(document, dict): + raise TypeError + units = document["translation-units"] + if not isinstance(units, list) or not units: + raise TypeError + dependencies: list[Path] = [] + for unit in units: + commands = unit["commands"] + if not isinstance(commands, list) or not commands: + raise TypeError + for command in commands: + file_dependencies = command["file-deps"] + if not isinstance(file_dependencies, list) or not all( + isinstance(item, str) for item in file_dependencies + ): + raise TypeError + for item in file_dependencies: + path = Path(item) + if not path.is_absolute(): + raise TypeError + resolved = path.resolve(strict=True) + if not resolved.is_file(): + raise OSError + dependencies.append(resolved) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang-scan-deps scan output") from error + if expected_source not in dependencies: + raise ClangDependencyError("invalid clang-scan-deps scan output: source is absent") + unique = { + os.path.normcase(str(path)): str(path) + for path in dependencies + if path != expected_source + } + return { + "Data": { + "Source": str(expected_source), + "Includes": sorted(unique.values(), key=str.casefold), + } + } + + +def scan_dependencies( + source: Path, command: Path, scanner: Path, compiler: Path, build_directory: Path +) -> dict[str, object]: + """Run the exact scanner over the exact clang-cl command captured by MSBuild.""" + + scanner = _file(scanner, "scanner") + compilation = load_compile_command(command, source, compiler) + try: + build_directory = build_directory.resolve(strict=True) + except OSError as error: + raise ClangDependencyError( + f"invalid clang dependency build directory: {build_directory}" + ) from error + if not build_directory.is_dir(): + raise ClangDependencyError( + f"invalid clang dependency build directory: {build_directory}" + ) + database = build_directory / "compile_commands.json" + try: + database.write_text( + json.dumps([compilation], ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + argv = [ + str(scanner), + "-format=experimental-full", + f"-compilation-database={database}", + "-j", + "1", + ] + result = subprocess.run( + argv, + cwd=compilation["directory"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + except OSError as error: + raise ClangDependencyError(f"failed to launch clang-scan-deps: {scanner}") from error + if result.returncode: + detail = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "no diagnostic" + raise ClangDependencyError( + f"clang-scan-deps failed ({result.returncode}): {detail[:400]}" + ) + try: + document = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise ClangDependencyError("invalid clang-scan-deps JSON output") from error + return dependency_manifest(source, document) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + scan = commands.add_parser("scan") + scan.add_argument("source") + scan.add_argument("command_file") + scan.add_argument("scanner") + scan.add_argument("compiler") + args = parser.parse_args(argv) + raw_output = os.environ.get("OBSERVER_OUT_DIR") + if not raw_output or not (output := Path(raw_output)).is_dir(): + raise ClangDependencyError("OBSERVER_OUT_DIR must be an existing directory") + raw_build = os.environ.get("OBSERVER_BUILD_DIR") + if not raw_build or not (build := Path(raw_build)).is_dir(): + raise ClangDependencyError("OBSERVER_BUILD_DIR must be an existing directory") + document = scan_dependencies( + Path(args.source), + Path(args.command_file), + Path(args.scanner), + Path(args.compiler), + build, + ) + (output / "dependencies.json").write_text( + json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/clean.py b/tools/build/core/clean.py new file mode 100644 index 0000000..8e2da1d --- /dev/null +++ b/tools/build/core/clean.py @@ -0,0 +1,141 @@ +"""Conservative cleanup of the exact repository-local generated output tree.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import os +from pathlib import Path +import shutil +import stat + +from filelock import FileLock, Timeout + +from core.paths import BuildPaths, PathSafetyError + + +_MODES = ("all", "stale-work") + + +class CleanError(RuntimeError): + """Generated output cannot be proven safe and inactive.""" + + +def _tree(paths: BuildPaths, root: Path) -> None: + for directory, directories, files in root.walk(follow_symlinks=False): + for name in (*directories, *files): + current = paths.require_confined(directory / name, paths.output_root) + mode = os.lstat(current).st_mode + if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode): + raise CleanError(f"unsupported output entry type: {current}") + + +def _validate(paths: BuildPaths) -> bool: + output = paths.require_confined(paths.output_root, paths.output_root) + try: + mode = os.lstat(output).st_mode + except FileNotFoundError: + return False + if not stat.S_ISDIR(mode): + raise CleanError(f"output root is not a directory: {output}") + if output.resolve(strict=True) != paths.repository / "out": + raise CleanError(f"output root does not resolve to repository/out: {output}") + for child in output.iterdir(): + current = paths.require_confined(child, paths.output_root) + if current.name not in {"cas", "work"}: + raise CleanError(f"unexpected output entry: {current}") + if not stat.S_ISDIR(os.lstat(current).st_mode): + raise CleanError(f"output entry is not a directory: {current}") + _tree(paths, output) + return True + + +def _runs(paths: BuildPaths) -> tuple[Path, ...]: + result = [] + for entry in sorted(paths.work_root.iterdir()): + if entry == paths.locks_root: + continue + if not entry.is_dir(): + raise CleanError(f"unexpected work entry: {entry}") + try: + result.append(paths.run_work(entry.name)) + except PathSafetyError as error: + raise CleanError(f"unexpected work entry: {entry}") from error + return tuple(result) + + +def _require_inactive(paths: BuildPaths) -> tuple[Path, ...]: + inactive = [] + for entry in sorted(paths.locks_root.iterdir()): + if entry == paths.coordination_lock(): + continue + if not entry.is_file(): + raise CleanError(f"unexpected lock entry: {entry}") + try: + if entry.suffix == ".lock": + paths.lock(entry.stem) + elif entry.name.startswith("run-") and entry.suffix == ".lease": + paths.lease(entry.name[4:-6]) + else: + raise PathSafetyError("unknown lock name") + except PathSafetyError as error: + raise CleanError(f"unexpected lock entry: {entry}") from error + lock = FileLock(entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True) + try: + with lock: + pass + except Timeout as error: + raise CleanError(f"active build lock: {entry}") from error + inactive.append(entry) + return tuple(inactive) + + +def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]: + """Remove all output or inactive run work, never anything outside exact ``out``.""" + + if mode not in _MODES: + raise ValueError(f"unsupported clean mode: {mode}") + paths = BuildPaths(repository) + if not _validate(paths): + return () + paths.work_root.mkdir(exist_ok=True) + paths.locks_root.mkdir(exist_ok=True) + coordination = FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with coordination: + if not _validate(paths): + return () + runs = _runs(paths) + inactive = _require_inactive(paths) + if mode == "all": + removed = [] + if paths.cas_root.exists(): + removed.append(paths.cas_root) + shutil.rmtree(paths.cas_root) + for run in runs: + shutil.rmtree(run) + for lock in inactive: + lock.unlink() + return tuple(removed) + runs + inactive + for run in runs: + shutil.rmtree(run) + return runs + except Timeout as error: + raise CleanError("active build coordination lock") from error + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="observer-build clean") + parser.add_argument("repository", type=Path) + parser.add_argument("--mode", choices=_MODES, default="all") + args = parser.parse_args(argv) + for removed in clean(args.repository, args.mode): + print(removed) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/cpp_coverage.py b/tools/build/core/cpp_coverage.py new file mode 100644 index 0000000..c757e67 --- /dev/null +++ b/tools/build/core/cpp_coverage.py @@ -0,0 +1,68 @@ +"""Semantic policy for LLVM coverage reports.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from collections.abc import Mapping, Sequence + + +class CoverageError(RuntimeError): + pass + + +def _metric(totals: Mapping[str, object], name: str) -> tuple[int, int]: + try: + metric = totals[name] + if not isinstance(metric, Mapping): + raise TypeError + count, covered = metric["count"], metric["covered"] + if ( + isinstance(count, bool) + or not isinstance(count, int) + or isinstance(covered, bool) + or not isinstance(covered, int) + or count < 0 + or covered < 0 + or covered > count + ): + raise TypeError + return count, covered + except (KeyError, TypeError) as error: + raise CoverageError("malformed LLVM coverage totals") from error + + +def require_full_coverage(document: Mapping[str, object]) -> None: + """Require nonempty, exact 100% first-party line and branch coverage.""" + + try: + data = document["data"] + if not isinstance(data, list) or len(data) != 1 or not isinstance(data[0], Mapping): + raise TypeError + totals = data[0]["totals"] + if not isinstance(totals, Mapping): + raise TypeError + except (KeyError, TypeError) as error: + raise CoverageError("malformed LLVM coverage report") from error + + for name in ("lines", "branches"): + count, covered = _metric(totals, name) + if count == 0: + raise CoverageError(f"no first-party {name} in LLVM coverage report") + if covered != count: + raise CoverageError(f"first-party {name} coverage is {covered}/{count}, required 100%") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + gate = commands.add_parser("gate") + gate.add_argument("report") + args = parser.parse_args(argv) + require_full_coverage(json.loads(Path(args.report).read_text(encoding="utf-8-sig"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/doctor.py b/tools/build/core/doctor.py new file mode 100644 index 0000000..beaf7d9 --- /dev/null +++ b/tools/build/core/doctor.py @@ -0,0 +1,79 @@ +"""Read-only, fail-soft discovery of complete local build prerequisites.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +import sys +import tomllib + +from core.quality_tools import discover_quality_tools, resolve_sanitizer_runtimes +from core.source_tools import discover_source_tools +from core.toolchain import discover_msvc_toolchain + +_MSVC = (("msbuild_version", "MSBuild"), ("vc_tools_version", "MSVC"), + ("clang_tidy_version", "LLVM"), ("windows_sdk_version", "SDK")) +_SOURCE = (("pwsh_version", "PowerShell"), ("clang_format_version", "clang-format"), + ("cppcheck_version", "Cppcheck"), ("psscriptanalyzer_version", "PSScriptAnalyzer")) + +@dataclass(frozen=True, slots=True) +class Probe: + name: str + status: str + detail: str + +def _python_version() -> str: + config = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + required = config["project"]["requires-python"] + actual = ".".join(map(str, sys.version_info[:3])) + if required != f"=={actual}": + raise RuntimeError(f"requires {required}, running {actual}") + return actual + +def _versions(value: object, fields: tuple[tuple[str, str], ...]) -> str: + identity = dict(value.identity) # type: ignore[attr-defined] + return ", ".join(f"{label}={identity[key]}" for key, label in fields) + +def _concise(error: Exception) -> str: + return " ".join(str(error).split()) or type(error).__name__ + +def doctor_report() -> tuple[Probe, ...]: + rows: list[Probe] = [] + + def probe(name: str, action: Callable[[], object], + detail: Callable[[object], str]) -> object | None: + try: + value = action() + rows.append(Probe(name, "OK", detail(value))) + return value + except Exception as error: # doctor must preserve the remaining independent probes + rows.append(Probe(name, "MISSING", _concise(error))) + return None + + def require_toolchain() -> object: + if toolchain is None: + raise RuntimeError("MSVC toolchain unavailable") + return toolchain + + probe("python", _python_version, str) + toolchain = probe("msvc", discover_msvc_toolchain, lambda value: _versions(value, _MSVC)) + probe("source-tools", lambda: discover_source_tools(require_toolchain()), + lambda value: _versions(value, _SOURCE)) + probe("quality-tools", lambda: discover_quality_tools(require_toolchain()), + lambda _value: "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH") + probe("sanitizer-runtimes", lambda: resolve_sanitizer_runtimes(require_toolchain()), + lambda _value: "ASan x86/x64, UBSan x64") + return tuple(rows) + +def main(argv: Sequence[str] | None = None) -> int: + argparse.ArgumentParser(prog="observer-build doctor").parse_args(argv) + report = doctor_report() + print("probe\tstatus\tdetail") + for item in report: + print(f"{item.name}\t{item.status}\t{item.detail}") + return int(any(item.status != "OK" for item in report)) + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/execute.py b/tools/build/core/execute.py new file mode 100644 index 0000000..3018061 --- /dev/null +++ b/tools/build/core/execute.py @@ -0,0 +1,105 @@ +"""Demand execution adapted from pg83/ix (MIT) at commit 66726a9.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import asynccontextmanager +from typing import AsyncContextManager + +from core.graph import Graph, GraphError, Node + + +class ExecutionError(RuntimeError): + """A node did not publish a complete cache entry.""" + + +CompletionPredicate = Callable[[Node], bool] +Runner = Callable[[Node], Awaitable[None]] +Publisher = Callable[[Node], None] +LockFactory = Callable[[Node], AsyncContextManager[object]] + + +@asynccontextmanager +async def _unlocked(_node: Node): + yield None + + +class Executor: + """Execute demanded ancestors once under named and shared-slot limits.""" + + def __init__( + self, + graph: Graph, + *, + is_complete: CompletionPredicate, + runner: Runner, + publish: Publisher, + acquire_lock: LockFactory = _unlocked, + ) -> None: + self._graph = graph + self._is_complete_hook = is_complete + self._runner = runner + self._publish = publish + self._acquire_lock = acquire_lock + self._visited: set[str] = set() + self._locks = {node.name: asyncio.Lock() for node in graph.nodes} + self._pools = { + name: asyncio.Semaphore(capacity) for name, capacity in graph.pools.items() + } + self._global = self._pools.get("slot") + self._used = False + + async def run(self, targets: tuple[str, ...] | None = None) -> None: + if self._used: + raise ExecutionError("an Executor is single-use") + self._used = True + requested = self._graph.targets if targets is None else tuple(targets) + if not requested: + raise GraphError("explicit target set must not be empty") + await self._visit_many(tuple(self._graph.node(name) for name in requested)) + + async def _visit(self, current: Node) -> None: + async with self._locks[current.name]: + if current.name in self._visited: + return + if self._is_complete(current): + self._visited.add(current.name) + return + + async with self._acquire_lock(current): + if self._is_complete(current): + self._visited.add(current.name) + return + await self._visit_many(self._graph.dependencies_of(current.name)) + async with self._capacity(current): + await self._runner(current) + self._publish(current) + if not self._is_complete(current): + raise ExecutionError( + f"node {current.name!r} returned without complete output" + ) + self._visited.add(current.name) + + @asynccontextmanager + async def _capacity(self, current: Node): + pool = self._pools[current.pool] + async with pool: + if self._global is None or pool is self._global: + yield + else: + async with self._global: + yield + + def _is_complete(self, current: Node) -> bool: + result = self._is_complete_hook(current) + if not isinstance(result, bool): + raise ExecutionError( + f"completion predicate for node {current.name!r} did not return bool" + ) + return result + + async def _visit_many(self, nodes: tuple[Node, ...]) -> None: + async with asyncio.TaskGroup() as tasks: + for current in nodes: + tasks.create_task(self._visit(current)) diff --git a/tools/build/core/graph.py b/tools/build/core/graph.py new file mode 100644 index 0000000..b0fa660 --- /dev/null +++ b/tools/build/core/graph.py @@ -0,0 +1,182 @@ +"""Small named DAG model adapted from pg83/ix (MIT) at commit 66726a9.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from graphlib import CycleError, TopologicalSorter +import re +from types import MappingProxyType +from typing import Mapping + + +class GraphError(ValueError): + """The graph is incomplete, ambiguous, or cyclic.""" + + +NODE_SLUG_MAX_LENGTH = 128 +_SLUG = re.compile(rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}") +_MD5_UID = re.compile(r"[0-9a-f]{32}") + + +def _text(value: object, description: str) -> str: + if not isinstance(value, str) or not value or "\0" in value: + raise GraphError(f"{description} must be a non-empty string without NUL") + return value + + +@dataclass(frozen=True, slots=True) +class Command: + """One literal process invocation; no field is shell-translated.""" + + argv: tuple[str, ...] + env: tuple[tuple[str, str], ...] = () + cwd: str | None = None + stdin: bytes = b"" + + def __post_init__(self) -> None: + argv = tuple(self.argv) + if not argv: + raise GraphError("command argv must not be empty") + for argument in argv: + if not isinstance(argument, str) or "\0" in argument: + raise GraphError("command argument must be a string without NUL") + + try: + env = tuple((key, value) for key, value in self.env) + except (TypeError, ValueError) as error: + raise GraphError("command environment must contain key/value pairs") from error + for key, value in env: + _text(key, "environment key") + if not isinstance(value, str) or "\0" in value: + raise GraphError("environment value must be a string without NUL") + folded_keys = [key.casefold() for key, _value in env] + if len(folded_keys) != len(set(folded_keys)): + raise GraphError("command environment contains duplicate keys") + if self.cwd is not None: + _text(self.cwd, "command cwd") + if type(self.stdin) is not bytes: + raise GraphError("command stdin must be exact bytes") + + object.__setattr__(self, "argv", argv) + object.__setattr__(self, "env", tuple(sorted(env, key=lambda pair: pair[0].casefold()))) + + +@dataclass(frozen=True, slots=True) +class Node: + """One cacheable command whose inputs name its direct dependency nodes.""" + + name: str + uid: str + pool: str + command: Command + inputs: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or _SLUG.fullmatch(self.name) is None: + raise GraphError( + "node name must be a lowercase readable slug of at most " + f"{NODE_SLUG_MAX_LENGTH} ASCII characters" + ) + if not isinstance(self.uid, str) or _MD5_UID.fullmatch(self.uid) is None: + raise GraphError("node uid must be a 32-character lowercase MD5") + _text(self.pool, "node pool") + if not isinstance(self.command, Command): + raise GraphError("node command must be a Command") + inputs = tuple(self.inputs) + for dependency in inputs: + _text(dependency, "dependency name") + if len(inputs) != len(set(inputs)): + raise GraphError(f"node {self.name!r} contains duplicate dependencies") + object.__setattr__(self, "inputs", inputs) + + +@dataclass(frozen=True, slots=True) +class Graph: + """Validated DAG keyed only by readable node names.""" + + nodes: tuple[Node, ...] + targets: tuple[str, ...] + pools: Mapping[str, int] + _by_name: Mapping[str, Node] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + nodes = tuple(self.nodes) + targets = tuple(self.targets) + pools = dict(self.pools) + if not nodes: + raise GraphError("graph must contain at least one node") + if not targets: + raise GraphError("graph must contain at least one target") + + by_name: dict[str, Node] = {} + for current in nodes: + if not isinstance(current, Node): + raise GraphError("graph nodes must be Node instances") + if current.name in by_name: + raise GraphError(f"duplicate node name: {current.name}") + by_name[current.name] = current + + for name, capacity in pools.items(): + _text(name, "pool name") + if isinstance(capacity, bool) or not isinstance(capacity, int) or capacity <= 0: + raise GraphError(f"invalid pool capacity for {name!r}: {capacity!r}") + for current in nodes: + if current.pool not in pools: + raise GraphError(f"node {current.name!r} uses unknown pool {current.pool!r}") + for dependency in current.inputs: + if dependency not in by_name: + raise GraphError( + f"node {current.name!r} has unknown dependency {dependency!r}" + ) + + for target in targets: + _text(target, "target node name") + if target not in by_name: + raise GraphError(f"unknown target node {target!r}") + if len(targets) != len(set(targets)): + raise GraphError("graph contains duplicate targets") + + dependencies = { + name: by_name[name].inputs for name in sorted(by_name) + } + try: + TopologicalSorter(dependencies).prepare() + except CycleError as error: + cycle = error.args[1] if len(error.args) > 1 else () + raise GraphError("dependency cycle: " + " -> ".join(cycle)) from error + + object.__setattr__(self, "nodes", nodes) + object.__setattr__(self, "targets", targets) + object.__setattr__(self, "pools", MappingProxyType(pools)) + object.__setattr__(self, "_by_name", MappingProxyType(by_name)) + + def node(self, name: str) -> Node: + try: + return self._by_name[name] + except KeyError as error: + raise GraphError(f"unknown node {name!r}") from error + + def dependencies_of(self, name: str) -> tuple[Node, ...]: + return tuple(self.node(dependency) for dependency in self.node(name).inputs) + + +def merge_graphs(*graphs: Graph) -> Graph: + """Union independent graphs while deduplicating byte-identical shared nodes.""" + + if not graphs: + raise GraphError("graph merge requires at least one graph") + nodes: dict[str, Node] = {} + targets: dict[str, None] = {} + pools: dict[str, int] = {} + for graph in graphs: + for current in graph.nodes: + previous = nodes.get(current.name) + if previous is not None and previous != current: + raise GraphError(f"conflicting node definition: {current.name}") + nodes.setdefault(current.name, current) + targets.update((name, None) for name in graph.targets) + for name, capacity in graph.pools.items(): + if name in pools and pools[name] != capacity: + raise GraphError(f"conflicting pool capacity for {name}") + pools[name] = capacity + return Graph(tuple(nodes.values()), tuple(targets), pools) diff --git a/tools/build/core/host.py b/tools/build/core/host.py new file mode 100644 index 0000000..19cde1d --- /dev/null +++ b/tools/build/core/host.py @@ -0,0 +1,107 @@ +"""Windows host architecture and local test execution policy.""" + +from __future__ import annotations + +from dataclasses import dataclass +import platform + + +_MACHINE_ARCHITECTURES = { + "amd64": "x64", + "x86_64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + "x86": "x86", + "i386": "x86", + "i686": "x86", +} +_RUNNABLE = { + "x86": frozenset({"x86"}), + "x64": frozenset({"x86", "x64"}), + "arm64": frozenset({"x86", "x64", "arm64"}), +} + + +@dataclass(frozen=True, slots=True) +class DeferredGate: + gate: str + architecture: str + reason: str + + +@dataclass(frozen=True, slots=True) +class VerifyRoute: + runnable: tuple[str, ...] + coverage: tuple[str, ...] + asan: tuple[str, ...] + ubsan: tuple[str, ...] + deferred: tuple[DeferredGate, ...] + + @property + def run_x64_specialists(self) -> bool: + return bool(self.ubsan) + + +def detect_host_architecture(machine: str | None = None) -> str: + value = platform.machine() if machine is None else machine + try: + return _MACHINE_ARCHITECTURES[value.casefold()] + except KeyError as error: + raise RuntimeError(f"unsupported Windows host architecture: {value}") from error + + +def runnable_architectures( + requested: tuple[str, ...], host_architecture: str | None = None +) -> tuple[str, ...]: + host = detect_host_architecture() if host_architecture is None else host_architecture + if host not in _RUNNABLE: + raise ValueError(f"unsupported host architecture: {host}") + unsupported = set(requested) - _RUNNABLE.keys() + if unsupported: + raise ValueError(f"unsupported requested architecture: {sorted(unsupported)[0]}") + return tuple(architecture for architecture in requested if architecture in _RUNNABLE[host]) + + +def require_runnable( + requested: tuple[str, ...], host_architecture: str | None = None +) -> tuple[str, ...]: + runnable = runnable_architectures(requested, host_architecture) + if runnable != requested: + missing = next(architecture for architecture in requested if architecture not in runnable) + host = detect_host_architecture() if host_architecture is None else host_architecture + raise RuntimeError(f"cannot run {missing} tests on {host} host") + return runnable + + +def verify_route( + requested: tuple[str, ...], host_architecture: str | None = None +) -> VerifyRoute: + """Route locally executable verify work and preserve explicit deferrals.""" + + runnable = runnable_architectures(requested, host_architecture) + missing = tuple(item for item in requested if item not in runnable) + deferred = [ + DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}") + for architecture in missing + for gate in ("tests", "package-runtime") + ] + specialists = ( + ("coverage", ("x64",)), + ("asan", ("x86", "x64")), + ("ubsan", ("x64",)), + ("leaks", ("x64",)), + ("fuzz", ("x64",)), + ) + deferred.extend( + DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}") + for gate, supported in specialists + for architecture in missing + if architecture in supported + ) + return VerifyRoute( + runnable, + tuple(item for item in runnable if item == "x64"), + tuple(item for item in runnable if item in {"x86", "x64"}), + tuple(item for item in runnable if item == "x64"), + tuple(deferred), + ) diff --git a/tools/build/core/leak.py b/tools/build/core/leak.py new file mode 100644 index 0000000..151b714 --- /dev/null +++ b/tools/build/core/leak.py @@ -0,0 +1,234 @@ +"""Small process-safe worker for fine-grained UMDH leak nodes.""" + +from __future__ import annotations + +from collections.abc import Sequence +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +import psutil + + +MODES = ("operations", "lifecycle") +SCENARIOS = ( + "small-success", "malformed", "cancellation", "read-failure", "write-failure", + "large-metadata", "sparse-metadata", +) +BINARIES = ("leak-probe.exe", "renpy.so", "rpgmaker.so", "zanzarah.so") + + +class LeakError(RuntimeError): + pass + + +def _output() -> Path: + value = os.environ.get("OBSERVER_OUT_DIR") + if not value or not (output := Path(value)).is_dir(): + raise LeakError("OBSERVER_OUT_DIR must be an existing directory") + return output + + +def _json(name: str, value: object) -> None: + (_output() / name).write_text( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _exact(action: str, args: Sequence[str], count: int) -> tuple[str, ...]: + if len(args) != count: + raise LeakError(f"{action} expects {count} arguments") + return tuple(args) + + +def _file(value: str, name: str) -> Path: + path = Path(value) + if not path.is_file(): + raise LeakError(f"{name} was not found: {path}") + return path + + +def _selection(mode: str, scenario: str) -> None: + if mode not in MODES or scenario not in SCENARIOS: + raise LeakError(f"invalid leak selection: {mode}/{scenario}") + + +def _count(value: str, name: str, *, minimum: int = 1) -> int: + try: + result = int(value) + except ValueError as error: + raise LeakError(f"{name} must be an integer") from error + if result < minimum: + raise LeakError(f"{name} must be at least {minimum}") + return result + + +def _marker(lines: Sequence[str], marker: str) -> str: + prefix = f"OBSERVER_LEAK_PROBE|{marker}|" + found = [line for line in lines if line.startswith(prefix)] + if len(found) != 1: + raise LeakError(f"leak probe emitted {len(found)} {marker} markers") + return found[0] + + +def _ready(line: str, mode: str, scenario: str, pid: int | None = None) -> None: + expected = rf"^OBSERVER_LEAK_PROBE\|READY\|pid=([0-9]+)\|mode={re.escape(mode)}\|configuration=Release\|scenarios={re.escape(scenario)}$" + match = re.fullmatch(expected, line) + if match is None or (pid is not None and int(match.group(1)) != pid): + raise LeakError("leak READY marker does not match the requested process/selection") + + +def _setup(args: Sequence[str]) -> None: + sources = tuple(Path(value) for value in _exact("setup", args, len(BINARIES))) + output, evidence = _output(), [] + for name, source in zip(BINARIES, sources, strict=True): + if not source.is_file(): + raise LeakError(f"leak binary was not found: {source}") + destination = output / name + shutil.copyfile(source, destination) + for symbol in source.parent.glob("*.pdb"): + shutil.copyfile(symbol, output / symbol.name) + with destination.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + evidence.append({"name": name, "path": str(destination), "sha256": digest}) + _json("release-binaries.json", {"architecture": "x64", "configuration": "Release", "runtimeLibrary": "MT_StaticRelease", "binaries": evidence}) + + +def _preflight(args: Sequence[str]) -> None: + directory, mode, scenario = _exact("preflight", args, 3) + _selection(mode, scenario) + probe = _file(str(Path(directory) / BINARIES[0]), "leak probe") + command = [str(probe), "--automatic", "--mode", mode, "--scenario", scenario, "--warmup", "1", "--iterations", "1", "--windows", "3"] + result = subprocess.run(command, cwd=directory, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") + if result.returncode: + raise LeakError(f"leak preflight failed ({result.returncode}): {result.stdout}") + lines = result.stdout.splitlines() + ready = _marker(lines, "READY") + _ready(ready, mode, scenario) + _marker(lines, "DONE") + _json("preflight.json", {"mode": mode, "scenario": scenario, "ready": ready}) + + +def _read(process: psutil.Popen[str], marker: str, label: str = "") -> str: + assert process.stdout is not None + prefix = f"OBSERVER_LEAK_PROBE|{marker}|{label + '|' if label else ''}" + while line := process.stdout.readline(): + if line.rstrip("\r\n").startswith(prefix): + return line.rstrip("\r\n") + raise LeakError(f"leak probe ended before {marker} marker") + + +def _kill_tree(process: psutil.Popen[str]) -> None: + processes = [*process.children(recursive=True), process] + for current in reversed(processes): + try: + current.kill() + except psutil.NoSuchProcess: + pass + psutil.wait_procs(processes, timeout=5) + + +def _snapshot(umdh: Path, pid: int, destination: Path, baseline: bool) -> None: + result = subprocess.run([str(umdh), f"-p:{pid}", f"-f:{destination}"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") + text = destination.read_text(encoding="utf-8", errors="replace") if destination.is_file() else "" + if baseline: + if result.returncode not in (0, 1) or (result.returncode == 1 and "enabled allocation stack collection" not in text): + raise LeakError(f"UMDH could not prime stack collection ({result.returncode}): {result.stdout}") + elif result.returncode or re.search(r"didn't find any allocations|database is full|stack trace database.*full", text, re.I) or "BackTrace" not in text: + raise LeakError(f"UMDH snapshot is unusable: {destination}") + + +def _capture(args: Sequence[str]) -> None: + directory, umdh_value, mode, scenario, warmup_value, iterations_value, windows_value = _exact("capture", args, 7) + _selection(mode, scenario) + probe, umdh = _file(str(Path(directory) / BINARIES[0]), "leak probe"), _file(umdh_value, "UMDH") + warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3) + output, snapshot_dir = _output(), _output() / "snapshots" + snapshot_dir.mkdir() + environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"} + command = [str(probe), "--mode", mode, "--scenario", scenario, "--warmup", str(warmup), "--iterations", str(iterations), "--windows", str(windows)] + error_path = output / "probe.stderr.log" + with error_path.open("w+", encoding="utf-8") as errors: + process = psutil.Popen(command, cwd=directory, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True, encoding="utf-8", errors="replace") + try: + _ready(_read(process, "READY"), mode, scenario, process.pid) + for label in ("baseline", *(f"window-{index}" for index in range(1, windows + 1))): + line = _read(process, "SNAPSHOT", label) + if not re.search(rf"\|pid={process.pid}\|", line): + raise LeakError("leak SNAPSHOT marker has the wrong PID") + _snapshot(umdh, process.pid, snapshot_dir / f"{label}.txt", label == "baseline") + assert process.stdin is not None + process.stdin.write(f"continue|{label}\n") + process.stdin.flush() + _read(process, "DONE") + try: + result = process.wait(timeout=120) + except psutil.TimeoutExpired as error: + raise LeakError("leak probe did not exit after its final snapshot") from error + if result: + errors.flush(); errors.seek(0) + raise LeakError(f"leak probe failed ({result}): {errors.read()}") + finally: + if process.poll() is None: + _kill_tree(process) + assert process.stdin is not None and process.stdout is not None + process.stdin.close() + process.stdout.close() + _json("capture.json", {"mode": mode, "scenario": scenario, "processId": process.pid, "windows": windows}) + + +def _diff(args: Sequence[str]) -> None: + umdh_value, directory, label, before, after = _exact("diff", args, 5) + report = _output() / "report.txt" + environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"} + result = subprocess.run([umdh_value, "-d", before, after, f"-f:{report}"], env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") + if result.returncode or not report.is_file(): + raise LeakError(f"UMDH comparison failed ({result.returncode}): {result.stdout}") + text = report.read_text(encoding="utf-8", errors="replace") + totals = list(re.finditer(r"^Total (increase|decrease)\s*==\s*([0-9]+)", text, re.M)) + if not totals: + raise LeakError("UMDH comparison contains no total allocation delta") + total = int(totals[-1].group(2)) * (-1 if totals[-1].group(1) == "decrease" else 1) + stacks = {match.group(2): int(match.group(1)) for match in re.finditer(r"^\+\s+([0-9]+)\s+\([^)]*\)\s+[0-9]+\s+allocs\s+BackTrace\s*([0-9A-Fa-f]+)", text, re.M)} + _json("diff.json", {"label": label, "totalIncrease": total, "positiveStacks": stacks, "report": "report.txt"}) + + +def _judge(args: Sequence[str]) -> None: + if len(args) < 8 or len(args) % 2: + raise LeakError("judge expects settings followed by label/report pairs") + mode, scenario, warmup_value, iterations_value, windows_value, tolerance_value, *pairs = args + _selection(mode, scenario) + warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3) + tolerance = _count(tolerance_value, "tolerance", minimum=0) + labels, records = pairs[::2], [json.loads(Path(path).read_text(encoding="utf-8")) for path in pairs[1::2]] + expected = [*(f"window-{index}" for index in range(1, windows)), "overall"] + if labels != expected or [record.get("label") for record in records] != expected: + raise LeakError("judge comparison labels do not match the measurement windows") + previous, last, overall = records[-3], records[-2], records[-1] + repeated = sorted(key for key, value in last["positiveStacks"].items() if value > tolerance and previous["positiveStacks"].get(key, 0) > tolerance) + sustained = last["totalIncrease"] > tolerance and previous["totalIncrease"] > tolerance and overall["totalIncrease"] > 2 * tolerance + summary = {"mode": mode, "scenario": scenario, "warmupRounds": warmup, "iterationsPerWindow": iterations, "windows": windows, "toleranceBytes": tolerance, "totalGrowthByWindow": [record["totalIncrease"] for record in records[:-1]], "overallGrowthBytes": overall["totalIncrease"], "repeatedGrowingStacks": repeated, "passed": not sustained and not repeated} + _json("summary.json", summary) + if not summary["passed"]: + raise LeakError("UMDH found sustained heap growth") + + +_ACTIONS = {"setup": _setup, "preflight": _preflight, "capture": _capture, "diff": _diff, "judge": _judge} + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or arguments[0] not in _ACTIONS: + raise LeakError("expected leak action: setup, preflight, capture, diff, or judge") + _ACTIONS[arguments[0]](arguments[1:]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/node.py b/tools/build/core/node.py new file mode 100644 index 0000000..0881b84 --- /dev/null +++ b/tools/build/core/node.py @@ -0,0 +1,58 @@ +"""Create signed graph nodes from rendered repository recipes.""" + +from dataclasses import dataclass +import json +from pathlib import Path + +from core.graph import Node +from core.recipe import Recipe +from core.render import TemplateRenderer +from core.sign import content_uid + + +@dataclass(frozen=True, slots=True) +class NodeFactory: + renderer: TemplateRenderer + cwd: Path + identity: dict[str, str] + environment: tuple[tuple[str, str], ...] = () + + def make( + self, + template: str, + name: str, + pool: str, + variables: dict[str, object], + *, + files: dict[str, bytes], + dependencies: tuple[Node, ...] = (), + config: dict[str, str], + identity: dict[str, str] | None = None, + environment: tuple[tuple[str, str], ...] | None = None, + cwd: Path | None = None, + ) -> Node: + descriptor = { + "name": name, + "pool": pool, + "inputs": [node.name for node in dependencies], + } | variables + rendered = self.renderer.render(template, descriptor) + node_environment = self.environment if environment is None else environment + node_cwd = cwd or self.cwd + runtime = json.dumps( + {"cwd": str(node_cwd), "environment": node_environment}, + ensure_ascii=False, + separators=(",", ":"), + ) + uid = content_uid( + recipe=rendered, + inputs=files, + dependencies={node.name: node.uid for node in dependencies}, + toolchain=identity or self.identity, + config=dict(config) | {"runtime": runtime}, + ) + return Recipe.parse(rendered).to_node( + uid=uid, + env=node_environment, + cwd=str(node_cwd), + ) diff --git a/tools/build/core/package.py b/tools/build/core/package.py new file mode 100644 index 0000000..d722c81 --- /dev/null +++ b/tools/build/core/package.py @@ -0,0 +1,221 @@ +"""Deterministic staging and ZIP creation for release packages.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +from collections.abc import Sequence +import zipfile + + +ARCHITECTURES = ("x86", "x64", "arm64") +LICENSES = { + "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"), + "rpgmaker": ("Observer.txt", "rgssad.txt"), + "zanzarah": ("Observer.txt", "zanzapak.txt"), +} +MODULES = tuple(LICENSES) +_ZIP_TIME = (1980, 1, 1, 0, 0, 0) + + +class PackageError(RuntimeError): + pass + + +def _output() -> Path: + value = os.environ.get("OBSERVER_OUT_DIR") + if not value or not (output := Path(value)).is_dir(): + raise PackageError("OBSERVER_OUT_DIR must be an existing directory") + return output + + +def _sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def _payload_files(payload: Path) -> dict[str, Path]: + return { + path.relative_to(payload).as_posix(): path + for path in sorted(payload.rglob("*")) + if path.is_file() + } + + +def _entries(files: dict[str, Path]) -> list[dict[str, str]]: + return [{"name": name, "sha256": _sha256(path)} for name, path in sorted(files.items())] + + +def _document(kind: str, architecture: str, module: str, payload: Path) -> dict[str, object]: + return { + "architecture": architecture, + "entries": _entries(_payload_files(payload)), + "kind": kind, + "module": module, + } + + +def _write_json(path: Path, value: object) -> None: + path.write_text( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _stage(args: argparse.Namespace, kind: str, files: dict[str, Path]) -> None: + output = _output() + payload = output / "payload" + for name, source in files.items(): + destination = payload / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + _write_json(output / "manifest.json", _document(kind, args.architecture, args.module, payload)) + + +def _stage_module(args: argparse.Namespace) -> None: + repository = args.repository + files = { + f"{args.module}.so": args.binary, + "observer_user.ini": repository / f"src/modules/{args.module}/observer_user.ini", + "docs/license.txt": repository / "LICENSE.txt", + } | {f"docs/thirdparty/{name}": repository / "licenses" / name for name in LICENSES[args.module]} + _stage(args, "module", files) + + +def _stage_symbol(args: argparse.Namespace) -> None: + _stage(args, "symbols", {f"{args.module}.pdb": args.symbol}) + + +def _stage_payload(stage: Path, kind: str, architecture: str, module: str) -> dict[str, Path]: + payload = stage / "payload" + expected = _document(kind, architecture, module, payload) + actual = json.loads((stage / "manifest.json").read_text(encoding="utf-8")) + if actual != expected: + raise PackageError(f"{kind} stage manifest does not match its payload") + return _payload_files(payload) + + +def _zip(destination: Path, files: dict[str, Path]) -> None: + with zipfile.ZipFile(destination, "w") as archive: + for name, path in sorted(files.items()): + information = zipfile.ZipInfo(name, _ZIP_TIME) + information.compress_type = zipfile.ZIP_DEFLATED + information.create_system = 3 + information.external_attr = 0o100644 << 16 + with path.open("rb") as source, archive.open(information, "w") as target: + shutil.copyfileobj(source, target, 1024 * 1024) + + +def _archive_module(args: argparse.Namespace) -> None: + files = _stage_payload(args.stage, "module", args.architecture, args.module) + _zip(_output() / f"{args.module}-{args.architecture}-dll.zip", files) + + +def _archive_symbols(args: argparse.Namespace) -> None: + _zip(_output() / f"observer-modules-{args.architecture}-pdb.zip", _symbol_payload(args)) + + +def _symbol_payload(args: argparse.Namespace) -> dict[str, Path]: + documents = [json.loads((stage / "manifest.json").read_text(encoding="utf-8")) for stage in args.stages] + modules = [document.get("module") for document in documents] + if set(modules) != set(MODULES) or len(modules) != len(MODULES): + raise PackageError("symbols archive requires the expected module set") + files = {} + for module, stage in sorted(zip(modules, args.stages, strict=True)): + files.update(_stage_payload(stage, "symbols", args.architecture, str(module))) + return files + + +def _archive_entries(path: Path) -> list[dict[str, str]]: + try: + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if len(members) != len({member.filename for member in members}): + raise PackageError("package archive does not match exact stage manifests") + entries = [] + for member in sorted(members, key=lambda item: item.filename): + with archive.open(member) as stream: + entries.append({"name": member.filename, "sha256": hashlib.file_digest(stream, "sha256").hexdigest()}) + return entries + except (OSError, zipfile.BadZipFile, RuntimeError) as error: + raise PackageError("package archive does not match exact stage manifests") from error + + +def _validate(archive: Path, files: dict[str, Path]) -> None: + entries = _archive_entries(archive) + if entries != _entries(files): + raise PackageError("package archive does not match exact stage manifests") + _write_json(_output() / "validation.json", {"entries": entries, "name": archive.name, "sha256": _sha256(archive)}) + + +def _validate_module(args: argparse.Namespace) -> None: + _validate(args.archive, _stage_payload(args.stage, "module", args.architecture, args.module)) + + +def _validate_symbols(args: argparse.Namespace) -> None: + _validate(args.archive, _symbol_payload(args)) + + +def _aggregate(args: argparse.Namespace) -> None: + names = [archive.name for archive in args.archives] + if len(names) != len(set(names)): + raise PackageError("duplicate archive name in package manifest") + _write_json( + _output() / "packages.json", + [ + {"name": archive.name, "sha256": _sha256(archive)} + for archive in sorted(args.archives, key=lambda path: path.name) + ], + ) + + +def _smoke(args: argparse.Namespace) -> None: + output = _output() + try: + with zipfile.ZipFile(args.archive) as archive: + module = Path(archive.extract(f"{args.module}.so", output)) + except KeyError as error: + raise PackageError(f"package archive has no {args.module}.so") from error + subprocess.run( + [str(args.tests), "[package-smoke]", "--reporter", "compact", "--rng-seed", "1"], + check=True, cwd=output, + env=os.environ | {"OBSERVER_PACKAGE_MODULE": str(module), "OBSERVER_PACKAGE_FORMAT": args.module}, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(required=True) + actions = ( + ("stage-module", ("architecture", "module", "binary", "repository"), _stage_module), + ("stage-symbol", ("architecture", "module", "symbol"), _stage_symbol), + ("archive-module", ("architecture", "module", "stage"), _archive_module), + ("archive-symbols", ("architecture", "stages"), _archive_symbols), + ("validate-module", ("architecture", "module", "archive", "stage"), _validate_module), + ("validate-symbols", ("architecture", "archive", "stages"), _validate_symbols), + ("aggregate", ("archives",), _aggregate), + ("smoke", ("architecture", "module", "archive", "tests"), _smoke), + ) + for command, names, action in actions: + current = commands.add_parser(command) + for name in names: + choices = ARCHITECTURES if name == "architecture" else MODULES if name == "module" else None + current.add_argument( + name, + choices=choices, + nargs="+" if name in {"stages", "archives"} else None, + type=None if choices else Path, + ) + current.set_defaults(run=action) + args = parser.parse_args(argv) + args.run(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/paths.py b/tools/build/core/paths.py new file mode 100644 index 0000000..93cb2da --- /dev/null +++ b/tools/build/core/paths.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import os +import re +import stat +from dataclasses import dataclass +from pathlib import Path + +from .graph import NODE_SLUG_MAX_LENGTH + + +_UID_PATTERN = re.compile(r"[0-9a-f]{32}\Z") +_NODE_PATTERN = re.compile( + rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}\Z" +) +_RUN_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + + +class PathSafetyError(ValueError): + """A build path did not satisfy the output confinement policy.""" + + +@dataclass(frozen=True) +class CasPaths: + entry: Path + output: Path + touch: Path + log: Path + + +def _is_reparse(path: Path) -> bool: + try: + information = os.lstat(path) + except FileNotFoundError: + return False + attributes = getattr(information, "st_file_attributes", 0) + reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute) + + +def _absolute(path: Path) -> Path: + return Path(os.path.abspath(os.fspath(path))) + + +def _is_within(path: Path, root: Path) -> bool: + return path == root or root in path.parents + + +class BuildPaths: + """Derive and validate the repository-local ``out/{cas,work}`` layout.""" + + def __init__(self, repository: Path | str) -> None: + repository_path = _absolute(Path(repository)) + if not repository_path.is_dir() or _is_reparse(repository_path): + raise PathSafetyError("repository must be an existing directory, not a reparse point") + + self.repository = repository_path + self.output_root = repository_path / "out" + self.cas_root = self.output_root / "cas" + self.work_root = self.output_root / "work" + self.locks_root = self.work_root / ".locks" + + def prepare(self) -> None: + for path, root in ( + (self.output_root, self.output_root), + (self.cas_root, self.cas_root), + (self.work_root, self.work_root), + (self.locks_root, self.work_root), + ): + self.require_confined(path, root).mkdir(exist_ok=True) + + def cas(self, uid: str, node: str) -> CasPaths: + self._require_uid(uid) + if _NODE_PATTERN.fullmatch(node) is None: + raise PathSafetyError( + "invalid lowercase node slug; expected at most " + f"{NODE_SLUG_MAX_LENGTH} ASCII characters: {node!r}" + ) + entry = self.require_confined(self.cas_root / f"{uid}-{node}", self.cas_root) + paths = CasPaths( + entry=entry, + output=entry / "out", + touch=entry / "touch", + log=entry / "log.txt", + ) + for path in (paths.output, paths.touch, paths.log): + self._reject_existing_reparse_points(path) + return paths + + def run_work(self, run_identifier: str) -> Path: + if _RUN_PATTERN.fullmatch(run_identifier) is None: + raise PathSafetyError(f"invalid run identifier: {run_identifier!r}") + return self.require_confined(self.work_root / run_identifier, self.work_root) + + def lock(self, uid: str) -> Path: + self._require_uid(uid) + return self.require_confined(self.locks_root / f"{uid}.lock", self.work_root) + + def coordination_lock(self) -> Path: + return self.require_confined(self.locks_root / "coordination.lock", self.work_root) + + def lease(self, run_identifier: str) -> Path: + self.run_work(run_identifier) + return self.require_confined( + self.locks_root / f"run-{run_identifier}.lease", self.work_root + ) + + def require_confined(self, candidate: Path | str, allowed_root: Path | str) -> Path: + path = _absolute(Path(candidate)) + root = _absolute(Path(allowed_root)) + if root not in (self.output_root, self.cas_root, self.work_root): + raise PathSafetyError(f"unexpected allowed root: {root}") + if not _is_within(path, root): + raise PathSafetyError(f"path is outside allowed root: {path}") + self._reject_existing_reparse_points(path) + return path + + def _reject_existing_reparse_points(self, path: Path) -> None: + if not _is_within(path, self.repository): + raise PathSafetyError(f"path is outside repository: {path}") + current = path + while True: + if _is_reparse(current): + raise PathSafetyError(f"reparse point is forbidden in build path: {current}") + if current == self.repository: + return + current = current.parent + + @staticmethod + def _require_uid(uid: str) -> None: + if _UID_PATTERN.fullmatch(uid) is None: + raise PathSafetyError(f"UID must be 32 lowercase hexadecimal characters: {uid!r}") diff --git a/tools/build/core/python_coverage.py b/tools/build/core/python_coverage.py new file mode 100644 index 0000000..841ab01 --- /dev/null +++ b/tools/build/core/python_coverage.py @@ -0,0 +1,56 @@ +"""Run the project-local coverage.py gate and publish its native evidence.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +from collections.abc import Sequence + + +def _directory(name: str) -> Path: + value = os.environ.get(name) + if not value or not (path := Path(value)).is_dir(): + raise RuntimeError(f"{name} must name an existing directory") + return path.resolve() + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("coverage", type=Path) + parser.add_argument("build_root", type=Path) + args = parser.parse_args(argv) + coverage = args.coverage.resolve(strict=True) + build_root = args.build_root.resolve(strict=True) + config = build_root / "pyproject.toml" + if not coverage.is_file() or not build_root.is_dir() or not config.is_file(): + raise FileNotFoundError("coverage executable, build root, and config must exist") + + work, output = _directory("OBSERVER_BUILD_DIR"), _directory("OBSERVER_OUT_DIR") + data = work / ".coverage" + command = [str(coverage)] + subprocess.run(command + ["run", "--rcfile", str(config), "--data-file", str(data), + "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], + cwd=build_root, check=True) + report = subprocess.run( + command + ["report", "--rcfile", str(config), "--data-file", str(data), "--fail-under=100"], + cwd=build_root, check=False, stdout=subprocess.PIPE, text=True, + ) + (output / "coverage.txt").write_text(report.stdout, encoding="utf-8") + subprocess.run(command + ["json", "--rcfile", str(config), "--data-file", str(data), + "--fail-under=0", "-o", str(output / "coverage.json")], + cwd=build_root, check=True) + subprocess.run(command + ["xml", "--rcfile", str(config), "--data-file", str(data), + "--fail-under=0", "-o", str(output / "coverage.xml")], + cwd=build_root, check=True) + shutil.copyfile(config, output / "coverage.toml") + shutil.copyfile(data, output / data.name) + print(report.stdout, end="") + report.check_returncode() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/quality_tools.py b/tools/build/core/quality_tools.py new file mode 100644 index 0000000..9ccc18d --- /dev/null +++ b/tools/build/core/quality_tools.py @@ -0,0 +1,169 @@ +"""Resolve immutable identities for optional local quality tools without installing them.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import shutil + +from core.toolchain import MsvcToolchain + + +@dataclass(frozen=True, slots=True) +class ResolvedTool: + path: Path + identity: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True, slots=True) +class ResolvedDirectory: + path: Path + files: tuple[ResolvedTool, ...] + + @property + def identity(self) -> tuple[tuple[str, str], ...]: + return (("path", str(self.path)),) + tuple( + (f"{tool.path.name}.{key}", value) + for tool in self.files + for key, value in tool.identity + ) + + +@dataclass(frozen=True, slots=True) +class QualityTools: + clang_cl: ResolvedTool + clang_scan_deps: ResolvedTool + llvm_cov: ResolvedTool + llvm_profdata: ResolvedTool + dumpbin: ResolvedTool + binskim: ResolvedTool + umdh: ResolvedTool + + +@dataclass(frozen=True, slots=True) +class SanitizerRuntimes: + asan_x86: ResolvedTool + asan_x64: ResolvedTool + ubsan: ResolvedDirectory + + +_LLVM_TOOLS = frozenset(("clang-cl", "clang-scan-deps", "llvm-cov", "llvm-profdata")) +UBSAN_LIBRARIES = ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", +) +_ASAN_RUNTIMES = { + "x86": "clang_rt.asan_dynamic-i386.dll", + "x64": "clang_rt.asan_dynamic-x86_64.dll", +} + + +def resolve_tool(path: Path | str | None, name: str) -> ResolvedTool: + try: + resolved = Path(path).resolve(strict=True) if path else None + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"missing {name}: {path}") from error + if resolved is None or not resolved.is_file(): + raise FileNotFoundError(f"missing {name}: {path}") + with resolved.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return ResolvedTool(resolved, (("path", str(resolved)), ("sha256", digest))) + + +def _identity_value(toolchain: MsvcToolchain, key: str, name: str) -> str: + value = dict(toolchain.identity).get(key) + if not value: + raise FileNotFoundError(f"missing {name} identity") + return value + + +def resolve_llvm(toolchain: MsvcToolchain, name: str) -> ResolvedTool: + if name not in _LLVM_TOOLS: + raise ValueError(f"unsupported LLVM tool: {name}") + return resolve_tool(toolchain.llvm_dir / f"bin/{name}.exe", name) + + +def resolve_dumpbin(toolchain: MsvcToolchain) -> ResolvedTool: + version = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version") + path = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe" + return resolve_tool(path, "dumpbin") + + +def resolve_binskim() -> ResolvedTool: + return resolve_tool(shutil.which("binskim"), "BinSkim") + + +def resolve_umdh() -> ResolvedTool: + program_files = os.environ.get("ProgramFiles(x86)") + candidates = (() if not program_files else tuple( + Path(program_files) / f"Windows Kits/{version}/Debuggers/x64/umdh.exe" + for version in ("11", "10") + )) + for candidate in candidates: + if candidate.is_file(): + return resolve_tool(candidate, "UMDH") + searched = ", ".join(map(str, candidates)) or "ProgramFiles(x86) is unset" + raise FileNotFoundError(f"missing UMDH: {searched}") + + +def resolve_asan_runtimes( + toolchain: MsvcToolchain, architectures: tuple[str, ...] +) -> tuple[tuple[str, ResolvedTool], ...]: + """Resolve only the requested MSVC ASan runtime DLLs.""" + + unsupported = next( + (architecture for architecture in architectures if architecture not in _ASAN_RUNTIMES), + None, + ) + if unsupported is not None: + raise ValueError(f"unsupported ASan architecture: {unsupported}") + msvc = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version") + asan = toolchain.installation / f"VC/Tools/MSVC/{msvc}/bin/Hostx64" + return tuple( + ( + architecture, + resolve_tool( + asan / architecture / _ASAN_RUNTIMES[architecture], + f"MSVC ASan {architecture} runtime", + ), + ) + for architecture in architectures + ) + + +def resolve_ubsan_runtime(toolchain: MsvcToolchain) -> ResolvedDirectory: + """Resolve only the LLVM x64 UBSan archive pair.""" + + llvm = _identity_value(toolchain, "clang_tidy_version", "LLVM clang_tidy_version") + root = toolchain.llvm_dir / "lib/clang" + candidates = tuple(root / version / "lib/windows" for version in dict.fromkeys((llvm, llvm.split(".")[0]))) + directory = next((candidate.resolve() for candidate in candidates if candidate.is_dir()), None) + if directory is None: + raise FileNotFoundError(f"missing UBSan runtime directory: {', '.join(map(str, candidates))}") + files = tuple(resolve_tool(directory / name, f"UBSan runtime {name}") for name in UBSAN_LIBRARIES) + return ResolvedDirectory(directory, files) + + +def resolve_sanitizer_runtimes(toolchain: MsvcToolchain) -> SanitizerRuntimes: + """Resolve every sanitizer runtime required by doctor and aggregate verification.""" + + asan = dict(resolve_asan_runtimes(toolchain, ("x86", "x64"))) + return SanitizerRuntimes( + asan["x86"], asan["x64"], resolve_ubsan_runtime(toolchain) + ) + + +def discover_quality_tools(toolchain: MsvcToolchain) -> QualityTools: + """Resolve the exact quality-tool files expected by coverage, sanitizer, audit, and leak graphs.""" + + return QualityTools( + clang_cl=resolve_llvm(toolchain, "clang-cl"), + clang_scan_deps=resolve_llvm(toolchain, "clang-scan-deps"), + llvm_cov=resolve_llvm(toolchain, "llvm-cov"), + llvm_profdata=resolve_llvm(toolchain, "llvm-profdata"), + dumpbin=resolve_dumpbin(toolchain), + binskim=resolve_binskim(), + umdh=resolve_umdh(), + ) diff --git a/tools/build/core/recipe.py b/tools/build/core/recipe.py new file mode 100644 index 0000000..8b92ebd --- /dev/null +++ b/tools/build/core/recipe.py @@ -0,0 +1,61 @@ +"""Bridge trusted repository-rendered JSON recipes to graph nodes.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +import json + +from core.graph import Command, Node + + +class RecipeError(ValueError): + """A required repository recipe field is missing or malformed.""" + + +@dataclass(frozen=True, slots=True) +class Recipe: + name: str + pool: str + inputs: tuple[str, ...] + argv: tuple[str, ...] + data: bytes + + @classmethod + def parse(cls, rendered: str | bytes) -> Recipe: + """Read only the fields emitted by the repository templates.""" + + try: + document = json.loads(rendered) + script = document["script"] + return cls( + name=document["name"], + pool=document["pool"], + inputs=tuple(document["inputs"]), + argv=tuple(script["exec"]), + data=script["data"].encode("utf-8"), + ) + except ( + json.JSONDecodeError, + UnicodeDecodeError, + KeyError, + TypeError, + AttributeError, + ) as error: + raise RecipeError("rendered recipe is missing required JSON fields") from error + + def to_node( + self, + *, + uid: str, + env: Mapping[str, str] | Iterable[tuple[str, str]] = (), + cwd: str | None = None, + ) -> Node: + environment = env.items() if isinstance(env, Mapping) else env + return Node( + name=self.name, + uid=uid, + pool=self.pool, + command=Command(self.argv, tuple(environment), cwd, self.data), + inputs=self.inputs, + ) diff --git a/tools/build/core/render.py b/tools/build/core/render.py new file mode 100644 index 0000000..aa1bfdf --- /dev/null +++ b/tools/build/core/render.py @@ -0,0 +1,55 @@ +"""Deterministic rendering for inherited build recipes.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import jinja2 + + +def ps_quote(value: object) -> str: + """Return *value* as one PowerShell single-quoted string literal.""" + + text = str(value) + return "'" + text.replace("'", "''") + "'" + + +def _json(value: object) -> str: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +class TemplateRenderer: + """Render flat, inherited Jinja recipes with undefined values forbidden.""" + + def __init__(self, template_dir: Path) -> None: + root = template_dir.resolve(strict=True) + if not root.is_dir(): + raise NotADirectoryError(root) + + self._environment = jinja2.Environment( + loader=jinja2.FileSystemLoader(root), + undefined=jinja2.StrictUndefined, + autoescape=False, + auto_reload=False, + trim_blocks=True, + lstrip_blocks=True, + keep_trailing_newline=True, + newline_sequence="\n", + ) + self._environment.filters["json"] = _json + self._environment.filters["ps_quote"] = ps_quote + + def render(self, template_name: str, variables: Mapping[str, Any]) -> str: + """Render *template_name* using an explicit variable mapping.""" + + template = self._environment.get_template(template_name) + return template.render(dict(variables)) diff --git a/tools/build/core/runtime.py b/tools/build/core/runtime.py new file mode 100644 index 0000000..7c2a3dd --- /dev/null +++ b/tools/build/core/runtime.py @@ -0,0 +1,115 @@ +"""Minimal bridge from graph execution to the repository-local Windows CAS.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from pathlib import Path +import shutil + +from filelock import AsyncFileLock + +from core.execute import Executor +from core.graph import Command, Graph, Node +from core.paths import BuildPaths +from core.store import CasStore +from core.windows_process import WindowsProcessRunner + + +class ProcessFailed(RuntimeError): + """A build node process returned a nonzero exit code.""" + + +class _LeasedExecutor: + def __init__(self, runtime: BuildRuntime, executor: Executor) -> None: + self._runtime = runtime + self._executor = executor + + async def run(self) -> None: + coordination = self._runtime._lock(self._runtime.paths.coordination_lock()) + lease = self._runtime._lock(self._runtime.paths.lease(self._runtime._run_id)) + async with coordination: + self._runtime.paths.prepare() + await lease.acquire() + try: + await self._executor.run() + finally: + await lease.release() + + +class BuildRuntime: + """Wire the generic executor to locks, CAS paths, and process execution.""" + + def __init__( + self, + repository: Path | str, + run_id: str, + process_runner: WindowsProcessRunner = WindowsProcessRunner(), + ) -> None: + self.paths = BuildPaths(repository) + self.store = CasStore(self.paths, run_id) + self._run_id = run_id + self._runner = process_runner + + def is_complete(self, current: Node) -> bool: + return self.store.is_complete(current) + + @staticmethod + def _lock(path: Path) -> AsyncFileLock: + return AsyncFileLock( + path, + timeout=-1, + poll_interval=0.05, + fallback_to_soft=False, + preserve_lock_file=True, + ) + + def lock(self, current: Node) -> AsyncFileLock: + return self._lock(self.paths.lock(current.uid)) + + async def run(self, current: Node) -> None: + reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR") + existing = {key.casefold() for key, _value in current.command.env} + for key in reserved: + if key.casefold() in existing: + raise ValueError(f"command environment conflicts with {key}") + + cas = self.store.prepare_entry(current) + run_root = self.paths.run_work(self._run_id) + work = self.paths.require_confined( + run_root / f"{current.uid}-{current.name}", self.paths.work_root + ) + work.mkdir(exist_ok=False) + command = Command( + current.command.argv, + env=current.command.env + + ( + ("OBSERVER_OUT_DIR", str(cas.output)), + ("OBSERVER_BUILD_DIR", str(work)), + ), + cwd=current.command.cwd, + stdin=current.command.stdin, + ) + with cas.log.open("r+b") as log: + exit_code = await self._runner.run(command, log=log) + if exit_code != 0: + raise ProcessFailed(f"process exited {exit_code} for node {current.name}") + scratch = self.paths.require_confined(work, self.paths.work_root) + await asyncio.to_thread(shutil.rmtree, scratch) + with suppress(OSError): + run_root.rmdir() + + def publish(self, current: Node) -> None: + self.store.mark_complete(current) + + def executor(self, graph: Graph) -> _LeasedExecutor: + return _LeasedExecutor( + self, + Executor( + graph, + is_complete=self.is_complete, + acquire_lock=self.lock, + runner=self.run, + publish=self.publish, + ), + ) diff --git a/tools/build/core/sanitizer.py b/tools/build/core/sanitizer.py new file mode 100644 index 0000000..f93f935 --- /dev/null +++ b/tools/build/core/sanitizer.py @@ -0,0 +1,50 @@ +"""Fail-closed semantic gates for native sanitizer logs.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path +import re + + +class SanitizerError(RuntimeError): + pass + + +_FINDINGS = { + "asan": re.compile( + r"(?:ERROR|SUMMARY):\s*AddressSanitizer|AddressSanitizer:DEADLYSIGNAL", + re.IGNORECASE, + ), + "ubsan": re.compile( + r"runtime error:|UndefinedBehaviorSanitizer(?::DEADLYSIGNAL|: undefined-behavior)", + re.IGNORECASE, + ), +} + + +def require_clean_log(sanitizer: str, content: str) -> None: + try: + pattern = _FINDINGS[sanitizer] + except KeyError as error: + raise SanitizerError(f"unsupported sanitizer: {sanitizer}") from error + if pattern.search(content): + raise SanitizerError(f"{sanitizer} finding in test log") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + gate = commands.add_parser("gate") + gate.add_argument("sanitizer", choices=tuple(_FINDINGS)) + gate.add_argument("log") + args = parser.parse_args(argv) + require_clean_log( + args.sanitizer, Path(args.log).read_text(encoding="utf-8-sig") + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/sarif.py b/tools/build/core/sarif.py new file mode 100644 index 0000000..23533df --- /dev/null +++ b/tools/build/core/sarif.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +from collections.abc import Iterable, Sequence +from pathlib import Path +from typing import Any + + +_DIAGNOSTIC = re.compile( + r"^(.+)\((\d+),(\d+)\):\s+(warning|error)\s*:\s*(.*?)\s+\[([^\]]+)\]" + r"(?:\s+\[[^\]]+\.vcxproj\])?\s*$" +) + + +class SarifError(ValueError): + pass + + +class SarifFindingsError(SarifError): + pass + + +def _read(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]: + try: + document = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as error: + raise SarifError(f"cannot read SARIF report {path}: {error}") from error + if not isinstance(document, dict) or document.get("version") != "2.1.0": + raise SarifError(f"SARIF report is not version 2.1.0: {path}") + runs = document.get("runs") + if not isinstance(runs, list) or not runs or any(not isinstance(run, dict) for run in runs): + raise SarifError(f"SARIF runs must be a non-empty list of objects: {path}") + return document, runs + + +def _write(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _document(runs: list[dict[str, Any]]) -> dict[str, Any]: + return {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": runs, "version": "2.1.0"} + + +def _tidy_result(finding: tuple[str, int, int, str, str, str]) -> dict[str, Any]: + path, line, column, level, rule, message = finding + location = { + "artifactLocation": {"uri": path}, + "region": {"startColumn": column, "startLine": line}, + } + return { + "level": level, + "locations": [{"physicalLocation": location}], + "message": {"text": message}, + "ruleId": rule, + } + + +def _tidy_rule(rule: str) -> dict[str, Any]: + return {"id": rule, "name": rule, "shortDescription": {"text": f"clang-tidy check {rule}"}} + + +def clang_tidy_to_sarif( + repository: Path, log_root: Path, output: Path, automation_id: str +) -> None: + repository = repository.resolve() + findings: set[tuple[str, int, int, str, str, str]] = set() + for log in sorted(log_root.rglob("*.ClangTidy.log")) if log_root.is_dir() else (): + for line in log.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + match = _DIAGNOSTIC.match(line) + if not match: + continue + rule = next( + (check for raw in match[6].split(",") if (check := raw.strip()) and not check.startswith("-")), + None, + ) + if not rule: + continue + try: + relative = Path(match[1]).resolve().relative_to(repository).as_posix() + except (OSError, ValueError): + continue + line_number, column, level = int(match[2]), int(match[3]), match[4] + findings.add((relative, line_number, column, level, rule, match[5].strip())) + + ordered = sorted(findings, key=lambda item: (item[0], item[1], item[2], item[4], item[5], item[3])) + driver = { + "informationUri": "https://clang.llvm.org/extra/clang-tidy/", + "name": "clang-tidy", + "rules": [_tidy_rule(rule) for rule in sorted({finding[4] for finding in findings})], + } + run = { + "automationDetails": {"id": automation_id}, + "results": [_tidy_result(finding) for finding in ordered], + "tool": {"driver": driver}, + } + _write(output, _document([run])) + + +def normalize_msvc(source: Path, output: Path, automation_id: str) -> None: + document, runs = _read(source) + for index, run in enumerate(runs, 1): + details = run.get("automationDetails") + if not isinstance(details, dict): + details = run["automationDetails"] = {} + details["id"] = automation_id if len(runs) == 1 else f"{automation_id.rstrip('/')}/run-{index}/" + _write(output, document) + + +def merge_sarif(inputs: Iterable[Path], output: Path) -> None: + identified: list[tuple[str, dict[str, Any]]] = [] + for path in inputs: + _, runs = _read(path) + for run in runs: + details = run.get("automationDetails") + identity = details.get("id") if isinstance(details, dict) else None + if not isinstance(identity, str) or not identity: + raise SarifError(f"SARIF run has no automationDetails.id: {path}") + identified.append((identity, run)) + if not identified: + raise SarifError("SARIF merge requires at least one input") + identities = [identity for identity, _ in identified] + if len(set(identities)) != len(identities): + raise SarifError("SARIF merge found duplicate automationDetails.id values") + _write(output, _document([run for _, run in sorted(identified)])) + + +def require_clean(inputs: Iterable[Path]) -> None: + count = 0 + for path in inputs: + _, runs = _read(path) + for run in runs: + results = run.get("results", []) + if not isinstance(results, list) or any(not isinstance(result, dict) for result in results): + raise SarifError(f"SARIF results must be a list of objects: {path}") + count += sum(result.get("level", "warning") in {"warning", "error"} for result in results) + if count: + raise SarifFindingsError(f"analysis found {count} warning/error finding(s)") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + + def add_command(name: str, *arguments: str, output: str | None = None) -> None: + command = commands.add_parser(name) + for argument in arguments: + command.add_argument(argument, **({"nargs": "+"} if argument == "inputs" else {})) + if output: + command.add_argument("--output-name", default=output) + + add_command("normalize-msvc", "input", "automation_id", output="renpy.sarif") + add_command("convert-tidy", "repository", "log_root", "automation_id", output="renpy.sarif") + add_command("merge", "inputs", output="analysis.sarif") + add_command("gate", "input") + args = parser.parse_args(argv) + + raw_root = os.environ.get("OBSERVER_OUT_DIR") + if not raw_root: + parser.error("OBSERVER_OUT_DIR is required") + root = Path(raw_root).resolve() + if not root.is_dir(): + parser.error("OBSERVER_OUT_DIR must be an existing directory") + if args.command == "gate": + require_clean((Path(args.input),)) + return 0 + output = (root / args.output_name).resolve() + if output == root or not output.is_relative_to(root): + parser.error("output name must be confined to OBSERVER_OUT_DIR") + if args.command == "normalize-msvc": + normalize_msvc(Path(args.input), output, args.automation_id) + elif args.command == "convert-tidy": + clang_tidy_to_sarif(Path(args.repository), Path(args.log_root), output, args.automation_id) + else: + merge_sarif([Path(path) for path in args.inputs], output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/core/sign.py b/tools/build/core/sign.py new file mode 100644 index 0000000..cd0820f --- /dev/null +++ b/tools/build/core/sign.py @@ -0,0 +1,83 @@ +"""Canonical MD5 identities for rendered content-addressed recipes.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from typing import TypeAlias + + +JsonScalar: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = ( + JsonScalar | list["JsonValue"] | tuple["JsonValue", ...] | Mapping[str, "JsonValue"] +) +_MD5_UID = re.compile(r"[0-9a-f]{32}") + + +def _items( + mapping: Mapping[str, object], description: str +) -> list[tuple[str, object]]: + result: list[tuple[str, object]] = [] + for key, value in mapping.items(): + if not isinstance(key, str): + raise TypeError(f"{description} must be strings") + result.append((key, value)) + return sorted(result) + + +def _identity(value: JsonValue) -> JsonValue: + if isinstance(value, Mapping): + return { + key: _identity(item) + for key, item in _items(value, "identity mapping keys") + } + if isinstance(value, (list, tuple)): + return [_identity(item) for item in value] + if value is None or isinstance(value, (bool, int, float, str)): + return value + raise TypeError(f"unsupported identity value: {type(value).__name__}") + + +def content_uid( + *, + recipe: str | bytes, + inputs: Mapping[str, bytes], + dependencies: Mapping[str, str], + toolchain: Mapping[str, JsonValue], + config: Mapping[str, JsonValue], +) -> str: + """Hash one unambiguous canonical-JSON description of a node.""" + + recipe_bytes = recipe.encode("utf-8") if isinstance(recipe, str) else recipe + if type(recipe_bytes) is not bytes: + raise TypeError("recipe must be str or bytes") + + input_data: list[list[str]] = [] + for name, data in _items(inputs, "input names"): + if type(data) is not bytes: + raise TypeError("input contents must be bytes") + input_data.append([name, data.hex()]) + + dependency_data: list[list[str]] = [] + for name, uid in _items(dependencies, "dependency names"): + if not isinstance(uid, str) or _MD5_UID.fullmatch(uid) is None: + raise ValueError(f"dependency {name!r} does not have a canonical MD5 UID") + dependency_data.append([name, uid]) + + payload = json.dumps( + { + "config": _identity(config), + "dependencies": dependency_data, + "format": "observer-build-content-v1", + "inputs": input_data, + "recipe": recipe_bytes.hex(), + "toolchain": _identity(toolchain), + }, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.md5(payload, usedforsecurity=False).hexdigest() diff --git a/tools/build/core/source_tools.py b/tools/build/core/source_tools.py new file mode 100644 index 0000000..0bdc086 --- /dev/null +++ b/tools/build/core/source_tools.py @@ -0,0 +1,69 @@ +"""Discover and fingerprint the repository source-check tools.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import shutil + +from core.toolchain import MsvcToolchain, _output + + +@dataclass(frozen=True, slots=True) +class SourceTools: + pwsh: Path + clang_format: Path + cppcheck: Path + psscriptanalyzer: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +def discover_source_tools(toolchain: MsvcToolchain) -> SourceTools: + """Locate source analyzers and capture their exact cache identity.""" + + pwsh = toolchain.pwsh.resolve(strict=True) + clang_format = (toolchain.llvm_dir / "bin/clang-format.exe").resolve(strict=True) + candidate = shutil.which("cppcheck.exe") + if candidate is None: + raise FileNotFoundError("cppcheck.exe was not found on PATH") + cppcheck = Path(candidate).resolve(strict=True) + vcpkg_root = toolchain.vcpkg_root.resolve(strict=True) + pwsh_version = _output([str(pwsh), "--version"]) + clang_format_version = _output([str(clang_format), "--version"]) + cppcheck_version = _output([str(cppcheck), "--version"]) + + pssa_query = """ +$module = Get-Module -ListAvailable PSScriptAnalyzer | + Sort-Object Version -Descending | + Select-Object -First 1 +if (-not $module) { throw 'PSScriptAnalyzer was not found' } +[ordered]@{ Path = $module.Path; Version = $module.Version.ToString() } | + ConvertTo-Json -Compress +""" + pssa_document = json.loads( + _output([str(pwsh), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", pssa_query]) + ) + psscriptanalyzer = Path(pssa_document["Path"]).resolve(strict=True) + identity = { + "clang_format": str(clang_format), + "clang_format_version": clang_format_version, + "cppcheck": str(cppcheck), + "cppcheck_version": cppcheck_version, + "psscriptanalyzer": str(psscriptanalyzer), + "psscriptanalyzer_version": str(pssa_document["Version"]), + "pwsh": str(pwsh), + "pwsh_version": pwsh_version, + "vcpkg_root": str(vcpkg_root), + } + return SourceTools( + pwsh, + clang_format, + cppcheck, + psscriptanalyzer, + vcpkg_root, + toolchain.environment, + tuple(identity.items()), + ) diff --git a/tools/build/core/store.py b/tools/build/core/store.py new file mode 100644 index 0000000..750461e --- /dev/null +++ b/tools/build/core/store.py @@ -0,0 +1,106 @@ +"""Repository-local CAS using pg83/ix's zero-byte publication marker.""" + +from __future__ import annotations + +import os +from pathlib import Path +import stat + +from core.graph import Node +from core.paths import BuildPaths, CasPaths + + +class CasStateError(RuntimeError): + """A CAS entry could not be prepared or safely published.""" + + +def _lstat(path: Path) -> os.stat_result | None: + try: + return os.lstat(path) + except FileNotFoundError: + return None + + +def _lstat_as(path: Path, file_type: int) -> os.stat_result | None: + information = _lstat(path) + if information is None or stat.S_IFMT(information.st_mode) != file_type: + return None + return information + + +def _complete(paths: CasPaths) -> bool: + marker = _lstat_as(paths.touch, stat.S_IFREG) + return ( + _lstat_as(paths.entry, stat.S_IFDIR) is not None + and _lstat_as(paths.output, stat.S_IFDIR) is not None + and _lstat_as(paths.log, stat.S_IFREG) is not None + and marker is not None + and marker.st_size == 0 + ) + + +class CasStore: + """Own completion state for one build run; callers hold the UID lock.""" + + def __init__(self, paths: BuildPaths, run_identifier: str) -> None: + self._paths = paths + self._run_work = paths.run_work(run_identifier) + + def paths_for(self, current: Node) -> CasPaths: + return self._paths.cas(current.uid, current.name) + + def is_complete(self, current: Node) -> bool: + return _complete(self.paths_for(current)) + + def prepare_entry(self, current: Node) -> CasPaths: + paths = self.paths_for(current) + if _complete(paths): + return paths + + self._prepare_run_directory() + if _lstat(paths.entry) is not None: + self._quarantine(paths) + + paths.entry.mkdir(exist_ok=False) + paths.output.mkdir(exist_ok=False) + with paths.log.open("xb"): + pass + return paths + + def mark_complete(self, current: Node) -> None: + paths = self.paths_for(current) + if _lstat_as(paths.entry, stat.S_IFDIR) is None or _lstat_as( + paths.output, stat.S_IFDIR + ) is None: + raise CasStateError("cannot publish completion without output directory") + if _lstat_as(paths.log, stat.S_IFREG) is None: + raise CasStateError("cannot publish completion without a regular log file") + + try: + with paths.touch.open("xb"): + pass + except FileExistsError as error: + raise CasStateError("completion marker already exists") from error + + def _prepare_run_directory(self) -> None: + self._paths.require_confined(self._run_work, self._paths.work_root) + self._run_work.mkdir(exist_ok=True) + + def _quarantine(self, paths: CasPaths) -> None: + quarantine_root = self._paths.require_confined( + self._run_work / "quarantine", self._paths.work_root + ) + quarantine_root.mkdir(exist_ok=True) + destination = self._paths.require_confined( + quarantine_root / paths.entry.name, self._paths.work_root + ) + if _lstat(destination) is not None: + raise CasStateError( + f"quarantine destination already exists: {destination}" + ) + try: + paths.entry.rename(destination) + except OSError as error: + raise CasStateError( + f"could not quarantine incomplete CAS entry {paths.entry}" + ) from error diff --git a/tools/build/core/toolchain.py b/tools/build/core/toolchain.py new file mode 100644 index 0000000..c078781 --- /dev/null +++ b/tools/build/core/toolchain.py @@ -0,0 +1,131 @@ +"""Discover the installed x64 MSVC analysis toolchain.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import shutil +import subprocess + + +@dataclass(frozen=True, slots=True) +class MsvcToolchain: + installation: Path + msbuild: Path + vsdevcmd: Path + llvm_dir: Path + clang_tidy: Path + vcpkg_root: Path + pwsh: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +def _existing(path: Path | str | None, directory: bool = False) -> Path: + resolved = Path(path).resolve() if path else None + if resolved is None or not (resolved.is_dir() if directory else resolved.is_file()): + raise FileNotFoundError(f"required path not found: {path}") + return resolved + + +def _command_environment(text: str) -> tuple[tuple[str, str], ...]: + values: dict[str, tuple[str, str]] = {} + for line in text.splitlines(): + if not line or line.startswith("="): + continue + key, value = line.split("=", 1) + folded = key.casefold() + canonical = key.upper() if folded in {"path", "lib"} else key + if folded == "lib": + value = os.pathsep.join( + part for part in value.split(os.pathsep) if Path(part).is_dir() + ) + values[folded] = (canonical, value) + + values.pop("__vscmd_preinit_path", None) + values.pop("path", None) + + inherited = {key.casefold(): value for key, value in os.environ.items()} + changed = ( + pair for folded, pair in values.items() if inherited.get(folded) != pair[1] + ) + return tuple(sorted(changed, key=lambda pair: pair[0].casefold())) + + +def _output(argv: list[str] | str, **options: str) -> str: + output = subprocess.run( + argv, check=True, capture_output=True, text=True, **options + ).stdout.strip() + if not output: + raise RuntimeError(f"tool returned empty output: {argv[0]}") + return output +def discover_msvc_toolchain() -> MsvcToolchain: + """Locate Visual Studio tools and capture an isolated amd64 developer environment.""" + + components = ( + "Microsoft.Component.MSBuild", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.VC.Llvm.Clang", + ) + vswhere = _existing( + Path(os.environ["ProgramFiles(x86)"]) + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + installation = _existing( + _output( + [ + str(vswhere), + "-latest", + "-products", + "*", + "-requires", + *components, + "-property", + "installationPath", + ] + ), + directory=True, + ) + bin_dir = installation / "MSBuild" / "Current" / "Bin" + amd64_msbuild = bin_dir / "amd64" / "MSBuild.exe" + msbuild = _existing(amd64_msbuild if amd64_msbuild.is_file() else bin_dir / "MSBuild.exe") + vsdevcmd = _existing(installation / "Common7" / "Tools" / "VsDevCmd.bat") + llvm_dir = _existing(installation / "VC" / "Tools" / "Llvm" / "x64", directory=True) + clang_tidy = _existing(llvm_dir / "bin" / "clang-tidy.exe") + cmd = _existing(Path(os.environ.get("SystemRoot", "")) / "System32" / "cmd.exe") + payload = f'call "{vsdevcmd}" -no_logo -arch=amd64 -host_arch=amd64 >nul && set' + environment = _command_environment( + _output(f'"{cmd}" /d /s /c "{payload}"', executable=str(cmd)) + ) + msbuild_version = _output([str(msbuild), "-version", "-nologo"]) + clang_output = _output([str(clang_tidy), "--version"]) + clang_tidy_version = next( + line.partition("LLVM version")[2].strip() + for line in clang_output.splitlines() + if "LLVM version" in line + ) + vcpkg_root = _existing( + os.environ.get("VCPKG_ROOT") + or _existing(shutil.which("vcpkg")).parent, + directory=True, + ) + pwsh = _existing(shutil.which("pwsh")) + captured = {key.casefold(): value for key, value in environment} + identity = ( + ("clang_tidy", str(clang_tidy)), + ("clang_tidy_version", clang_tidy_version), + ("installation", str(installation)), + ("msbuild", str(msbuild)), + ("msbuild_version", msbuild_version), + ("pwsh", str(pwsh)), + ("vc_tools_version", captured.get("vctoolsversion", "")), + ("vcpkg_root", str(vcpkg_root)), + ("vsdevcmd", str(vsdevcmd)), + ("vsdevcmd_version", captured.get("vscmd_ver", "")), + ("windows_sdk_version", captured.get("windowssdkversion", "")), + ) + return MsvcToolchain(installation, msbuild, vsdevcmd, llvm_dir, clang_tidy, + vcpkg_root, pwsh, environment, identity) diff --git a/tools/build/core/windows_job.py b/tools/build/core/windows_job.py new file mode 100644 index 0000000..3ec68d6 --- /dev/null +++ b/tools/build/core/windows_job.py @@ -0,0 +1,44 @@ +"""Minimal kill-on-close Windows Job Object wrapper.""" + +from __future__ import annotations + +import win32job + + +class WindowsJob: + """Own one Job handle until an explicit, observable close.""" + + def __init__(self) -> None: + handle = win32job.CreateJobObject(None, "") + self._handle = handle + try: + information = win32job.QueryInformationJobObject( + handle, win32job.JobObjectExtendedLimitInformation + ) + information["BasicLimitInformation"]["LimitFlags"] |= ( + win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + win32job.SetInformationJobObject( + handle, + win32job.JobObjectExtendedLimitInformation, + information, + ) + except BaseException as error: + self._handle = None + try: + handle.Close() + except BaseException as cleanup_error: + error.add_note(f"cleanup failure: {cleanup_error!r}") + raise + + def assign_process(self, process_handle: int) -> None: + win32job.AssignProcessToJobObject(self._handle, process_handle) + + def terminate(self) -> None: + win32job.TerminateJobObject(self._handle, 1) + + def close(self) -> None: + handle = self._handle + if handle is not None: + handle.Close() + self._handle = None diff --git a/tools/build/core/windows_process.py b/tools/build/core/windows_process.py new file mode 100644 index 0000000..c5961ed --- /dev/null +++ b/tools/build/core/windows_process.py @@ -0,0 +1,122 @@ +"""Small psutil-backed Windows process-tree runner.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import os +from pathlib import Path +import subprocess +from typing import BinaryIO + +import psutil + +from core.graph import Command +from core.windows_job import WindowsJob + + +_CREATE_SUSPENDED = 0x00000004 +_CREATE_UNICODE_ENVIRONMENT = 0x00000400 + + +def _attempt( + errors: list[BaseException], operation: Callable[[], object] +) -> bool: + try: + operation() + return True + except BaseException as error: + errors.append(error) + return False + + +def _stop( + job: WindowsJob, + process: psutil.Popen[bytes] | None, + assigned: bool, + errors: list[BaseException], +) -> None: + closed = _attempt(errors, job.close) + tree_stopped = assigned and closed + if assigned and not closed: + tree_stopped = _attempt(errors, job.terminate) + if process is not None and not tree_stopped: + _attempt(errors, process.kill) + + +async def _reap( + process: psutil.Popen[bytes], + communication: asyncio.Task[object] | None, + errors: list[BaseException], + primary: BaseException, +) -> None: + if communication is not None: + try: + await asyncio.shield(communication) + except BaseException as error: + if error is not primary: + errors.append(error) + if process.returncode is None: + await asyncio.to_thread(_attempt, errors, process.wait) + + +class WindowsProcessRunner: + """Run literal argv in a kill-on-close Job and stream exact stdin.""" + + async def run(self, command: Command, *, log: BinaryIO) -> int: + executable = Path(command.argv[0]) + if not executable.is_absolute() or not executable.is_file(): + raise ValueError("command executable must be an absolute existing file") + + job = WindowsJob() + process: psutil.Popen[bytes] | None = None + communication: asyncio.Task[object] | None = None + assigned = False + try: + environment = { + key.casefold(): (key, value) for key, value in os.environ.items() + } + for key, value in command.env: + folded = key.casefold() + if folded == "path" and folded in environment: + value += os.pathsep + environment[folded][1] + environment[folded] = (key, value) + process = psutil.Popen( + list(command.argv), + executable=command.argv[0], + shell=False, + stdin=subprocess.PIPE, + stdout=log, + stderr=subprocess.STDOUT, + cwd=command.cwd, + env=dict(environment.values()), + close_fds=True, + creationflags=_CREATE_SUSPENDED | _CREATE_UNICODE_ENVIRONMENT, + ) + job.assign_process(int(process._handle)) + assigned = True + process.resume() + communication = asyncio.create_task( + asyncio.to_thread(process.communicate, input=command.stdin) + ) + await asyncio.shield(communication) + if process.returncode is None: + raise RuntimeError("process completed without an exit code") + exit_code = process.returncode + except BaseException as primary: + errors: list[BaseException] = [] + _stop(job, process, assigned, errors) + if process is not None: + await _reap(process, communication, errors, primary) + for error in errors: + primary.add_note(f"cleanup failure: {error!r}") + raise + + errors = [] + _stop(job, process, assigned, errors) + if errors: + primary, *cleanup_errors = errors + for error in cleanup_errors: + primary.add_note(f"cleanup failure: {error!r}") + raise primary + return exit_code diff --git a/tools/build/driver.py b/tools/build/driver.py new file mode 100644 index 0000000..0bd9b00 --- /dev/null +++ b/tools/build/driver.py @@ -0,0 +1,415 @@ +"""Thin command orchestration over the independently tested build graphs.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import psutil + +from core.graph import Graph, Node, merge_graphs +from core.host import require_runnable, runnable_architectures, verify_route +from core.node import NodeFactory +from core.paths import BuildPaths +from core.quality_tools import ( + ResolvedTool, + resolve_binskim, + resolve_dumpbin, + resolve_asan_runtimes, + resolve_umdh, + resolve_ubsan_runtime, +) +from core.runtime import BuildRuntime +from core.source_tools import SourceTools, discover_source_tools +from core.toolchain import MsvcToolchain +from core.render import TemplateRenderer +from graphs.analysis import (analysis_discovery_slice, analysis_slice, + load_dependency_manifests) +from graphs.audit import BinaryArtifact, audit_graph +from graphs.common import BUILD_ROOT, require_positive_integers, restore_node, tool_environment +from graphs.coverage import coverage_dependency_discovery_slice, coverage_graph +from graphs.fuzz import ( + FUZZ_TARGETS, + FuzzCorpusArtifact, + fuzz_dependency_discovery_slice, + fuzz_graph, +) +from graphs.python_coverage import python_coverage_graph +from graphs.sanitizer import ( + AsanRuntime, + sanitizer_dependency_discovery_slice, + sanitizer_graph, +) +from graphs.leak import leak_graph +from graphs.native import native_dependency_discovery_slice, native_graph +from graphs.package import PackageArtifact, PackageSmokeArtifact, package_graph, package_outputs +from graphs.source import source_checks as source_checks_graph + + +_MODULES = ("renpy", "rpgmaker", "zanzarah") +_SANITIZER_ARCHITECTURES = { + "asan": ("ASan", frozenset(("x86", "x64"))), + "ubsan": ("UBSan", frozenset(("x64",))), +} + + +def _sanitizer_selections( + sanitizer: str, architectures: tuple[str, ...] +) -> tuple[tuple[str, str], ...]: + label, supported = _SANITIZER_ARCHITECTURES[sanitizer] + unsupported = next( + (architecture for architecture in architectures if architecture not in supported), + None, + ) + if unsupported is not None: + raise ValueError(f"{label} does not support {unsupported}") + return tuple((sanitizer, architecture) for architecture in require_runnable(architectures)) + + +class Driver: + """Own one repository-local runtime and compose command-specific graph families.""" + + def __init__(self, repository: Path, run_id: str, toolchain: MsvcToolchain, + *, jobs: int | None = None) -> None: + self.repository = repository.resolve(strict=True) + self.jobs = (psutil.cpu_count() or 1) if jobs is None else jobs + require_positive_integers((self.jobs,), "jobs must be a positive integer") + self.toolchain = toolchain + self.runtime = BuildRuntime(self.repository, run_id) + + async def _run(self, graph: Graph) -> tuple[Path, ...]: + await self.runtime.executor(graph).run() + return tuple(self.runtime.store.paths_for(graph.node(name)).output for name in graph.targets) + + async def _staged(self, discovery: Graph, compose: Callable[..., Graph], + **options: object) -> Graph: + await self._run(discovery) + return compose( + self.repository, self.toolchain, discovery=discovery, + manifests=load_dependency_manifests(self.repository, discovery), + jobs=self.jobs, **options, + ) + + async def _native(self, architectures: tuple[str, ...], configurations: tuple[str, ...], + runnable: tuple[str, ...], *, test_shards: int = 4, + include_leak_probe: bool = False, corpus: Path | None = None, + run_nonce: str = "") -> Graph: + discovery = native_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures, + configurations=configurations, include_leak_probe=include_leak_probe, + ) + return await self._staged( + discovery, native_graph, architectures=architectures, configurations=configurations, + runnable_architectures=runnable, test_shards=test_shards, + include_leak_probe=include_leak_probe, corpus=corpus, run_nonce=run_nonce, + ) + + def _release(self, graph: Graph, architecture: str, module: str) -> tuple[Node, Path]: + producer = graph.node(f"build-{module}-{architecture}-release") + return producer, BuildPaths(self.repository).cas(producer.uid, producer.name).output + + def _binaries(self, graph: Graph, architectures: tuple[str, ...], *, + include_leak_probe: bool = False) -> tuple[BinaryArtifact, ...]: + modules = (("leak-probe",) if include_leak_probe else ()) + _MODULES + return tuple( + BinaryArtifact(architecture, module, producer, output / + ("leak-probe.exe" if module == "leak-probe" else f"{module}.so")) + for architecture in architectures for module in modules + for producer, output in (self._release(graph, architecture, module),) + ) + + def _packages(self, graph: Graph, architectures: tuple[str, ...]) -> tuple[PackageArtifact, ...]: + return tuple( + PackageArtifact(architecture, module, producer, output / f"{module}.so", + output / f"{module}.pdb") + for architecture in architectures for module in _MODULES + for producer, output in (self._release(graph, architecture, module),) + ) + + def _smokes(self, graph: Graph, architectures: tuple[str, ...]) -> tuple[PackageSmokeArtifact, ...]: + return tuple( + PackageSmokeArtifact(architecture, producer, output / "tests.exe") + for architecture in architectures + for producer, output in (self._release(graph, architecture, "tests"),) + ) + + async def _audited_release(self, architectures: tuple[str, ...], dumpbin: ResolvedTool, + binskim: ResolvedTool, binskim_jobs: int, *, + include_leak_probe: bool = False) -> Graph: + upstream = await self._native( + architectures, ("Release",), (), include_leak_probe=include_leak_probe + ) + return audit_graph( + self.repository, upstream, self._binaries(upstream, architectures), + dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), + binskim=binskim.path, binskim_identity=dict(binskim.identity), + jobs=self.jobs, binskim_jobs=binskim_jobs, + ) + + async def restore(self, architectures: tuple[str, ...] = ("x64",), + flavors: tuple[str, ...] = ("",)) -> tuple[Path, ...]: + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), self.repository, {}, + tool_environment(self.toolchain), + ) + nodes = tuple( + restore_node(self.repository, self.toolchain, factory, architecture, flavor=flavor) + for flavor in flavors for architecture in architectures + ) + return await self._run( + Graph(nodes, tuple(node.name for node in nodes), {"restore": 1}) + ) + + async def build(self, architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",)) -> tuple[Path, ...]: + return await self._run(await self._native(architectures, configurations, ())) + + async def test(self, architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), *, test_shards: int = 4, + corpus: Path | None = None, run_nonce: str = "") -> tuple[Path, ...]: + graph = await self._native( + architectures, configurations, require_runnable(architectures), + test_shards=test_shards, corpus=corpus, run_nonce=run_nonce, + ) + return await self._run(graph) + + async def source_checks(self, architectures: tuple[str, ...], + tools: SourceTools) -> tuple[Path, ...]: + graph = source_checks_graph( + self.repository, tools, jobs=self.jobs, architectures=architectures + ) + return await self._run(graph) + + async def compiler_analysis(self, architectures: tuple[str, ...] = ("x64",)) -> tuple[Path, ...]: + discovery = analysis_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures + ) + graph = await self._staged(discovery, analysis_slice, architectures=architectures) + return await self._run(graph) + + async def test_coverage( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + report_jobs: int = 2, corpus: Path | None = None, run_nonce: str = "", + ) -> tuple[Path, ...]: + if corpus is not None and ( + not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce + ): + raise ValueError("corpus run nonce must be a non-empty string without NUL") + runnable = require_runnable(architectures) + discovery = coverage_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=runnable + ) + graph = await self._staged( + discovery, coverage_graph, architectures=runnable, + test_shards=test_shards, report_jobs=report_jobs, + corpus=corpus, run_nonce=run_nonce, + ) + return await self._run(graph) + + async def _sanitizer( + self, selections: tuple[tuple[str, str], ...], *, llvm_runtime: Path | None, + llvm_runtime_identity: dict[str, str], asan_runtimes: tuple[AsanRuntime, ...], + test_shards: int, + ) -> tuple[Path, ...]: + discovery = sanitizer_dependency_discovery_slice( + self.repository, self.toolchain, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity, + selections=selections, jobs=self.jobs, + ) + graph = await self._staged( + discovery, sanitizer_graph, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity, + asan_runtimes=asan_runtimes, selections=selections, + test_shards=test_shards, + ) + return await self._run(graph) + + async def test_asan( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + ) -> tuple[Path, ...]: + selections = _sanitizer_selections("asan", architectures) + runtimes = tuple( + AsanRuntime(architecture, tool.path, dict(tool.identity)) + for architecture, tool in resolve_asan_runtimes( + self.toolchain, tuple(architecture for _, architecture in selections) + ) + ) + return await self._sanitizer( + selections, llvm_runtime=None, llvm_runtime_identity={}, + asan_runtimes=runtimes, test_shards=test_shards, + ) + + async def test_ubsan( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + ) -> tuple[Path, ...]: + selections = _sanitizer_selections("ubsan", architectures) + runtime = resolve_ubsan_runtime(self.toolchain) + return await self._sanitizer( + selections, llvm_runtime=runtime.path, + llvm_runtime_identity=dict(runtime.identity), asan_runtimes=(), + test_shards=test_shards, + ) + + async def python_coverage(self) -> tuple[Path, ...]: + return await self._run(python_coverage_graph(self.repository)) + + async def fuzz(self, *, run_nonce: str, seconds: int = 60, fuzz_jobs: int = 2, + prior_corpora: tuple[FuzzCorpusArtifact, ...] = (), + targets: tuple[str, ...] = FUZZ_TARGETS) -> tuple[Path, ...]: + require_runnable(("x64",)) + discovery = fuzz_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, targets=targets + ) + graph = await self._staged( + discovery, fuzz_graph, run_nonce=run_nonce, seconds=seconds, + fuzz_jobs=fuzz_jobs, prior_corpora=prior_corpora, targets=targets, + ) + return await self._run(graph) + + async def audit(self, architectures: tuple[str, ...] = ("x64",), *, + dumpbin: ResolvedTool, binskim: ResolvedTool, + binskim_jobs: int = 1) -> tuple[Path, ...]: + return await self._run( + await self._audited_release(architectures, dumpbin, binskim, binskim_jobs) + ) + + async def package(self, architectures: tuple[str, ...] = ("x64",), *, + dumpbin: ResolvedTool, binskim: ResolvedTool, + binskim_jobs: int = 1) -> tuple[Path, ...]: + audited = await self._audited_release(architectures, dumpbin, binskim, binskim_jobs) + graph = package_graph( + self.repository, audited, self._packages(audited, architectures), + smoke_tests=self._smokes(audited, runnable_architectures(architectures)), jobs=self.jobs, + ) + await self._run(graph) + return package_outputs(self.repository, graph) + + async def test_leaks(self, *, run_nonce: str, dumpbin: ResolvedTool, + binskim: ResolvedTool, umdh: ResolvedTool, binskim_jobs: int = 1, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, session_jobs: int = 2, + diff_jobs: int = 4) -> tuple[Path, ...]: + require_runnable(("x64",)) + audited = await self._audited_release( + ("x64",), dumpbin, binskim, binskim_jobs, include_leak_probe=True + ) + graph = leak_graph( + self.repository, audited, + self._binaries(audited, ("x64",), include_leak_probe=True), + umdh=umdh.path, umdh_identity=dict(umdh.identity), run_nonce=run_nonce, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, jobs=self.jobs, + session_jobs=session_jobs, diff_jobs=diff_jobs, + ) + return await self._run(graph) + + async def verify( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, + ) -> tuple[Path, ...]: + """Run every host-capable gate in one shared-pool graph after one discovery union.""" + + route = verify_route(architectures) + source_tools = discover_source_tools(self.toolchain) + dumpbin, binskim = resolve_dumpbin(self.toolchain), resolve_binskim() + asan_tools = resolve_asan_runtimes(self.toolchain, route.asan) if route.asan else () + ubsan = resolve_ubsan_runtime(self.toolchain) if route.ubsan else None + umdh = resolve_umdh() if route.run_x64_specialists else None + selections = tuple(("asan", item) for item in route.asan) + tuple( + ("ubsan", item) for item in route.ubsan + ) + llvm_runtime = ubsan.path if ubsan else None + llvm_identity = dict(ubsan.identity) if ubsan else {} + asan_runtimes = tuple( + AsanRuntime(architecture, tool.path, dict(tool.identity)) + for architecture, tool in asan_tools + ) + + discoveries = [ + analysis_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures + ), + native_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures, + configurations=("Debug", "Release"), + include_leak_probe=route.run_x64_specialists, + ), + ] + if route.coverage: + discoveries.append(coverage_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, + architectures=route.coverage, + )) + if selections: + discoveries.append(sanitizer_dependency_discovery_slice( + self.repository, self.toolchain, llvm_runtime=llvm_runtime, + llvm_runtime_identity=llvm_identity, selections=selections, jobs=self.jobs, + )) + if route.run_x64_specialists: + discoveries.append(fuzz_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, targets=FUZZ_TARGETS, + )) + discovery = merge_graphs(*discoveries) + await self._run(discovery) + manifests = load_dependency_manifests(self.repository, discovery) + + native = native_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=architectures, + configurations=("Debug", "Release"), runnable_architectures=route.runnable, + test_shards=test_shards, include_leak_probe=route.run_x64_specialists, + corpus=corpus, run_nonce=run_nonce, + ) + audit = audit_graph( + self.repository, native, self._binaries(native, architectures), + dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), + binskim=binskim.path, binskim_identity=dict(binskim.identity), + jobs=self.jobs, binskim_jobs=1, + ) + package = package_graph( + self.repository, audit, self._packages(audit, architectures), + smoke_tests=self._smokes(audit, route.runnable), jobs=self.jobs, + ) + graphs = [ + native, + analysis_slice( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=architectures, + ), + source_checks_graph( + self.repository, source_tools, jobs=self.jobs, architectures=architectures, + ), + python_coverage_graph(self.repository), + package, + ] + if route.coverage: + graphs.append(coverage_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=route.coverage, test_shards=test_shards, + corpus=corpus, run_nonce=run_nonce, + )) + if selections: + graphs.append(sanitizer_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_identity, + asan_runtimes=asan_runtimes, selections=selections, + jobs=self.jobs, test_shards=test_shards, + )) + if route.run_x64_specialists: + assert umdh is not None + graphs.extend(( + fuzz_graph( + self.repository, self.toolchain, discovery=discovery, + manifests=manifests, run_nonce=run_nonce, seconds=fuzz_seconds, + jobs=self.jobs, targets=FUZZ_TARGETS, + ), + leak_graph( + self.repository, audit, + self._binaries(audit, ("x64",), include_leak_probe=True), + umdh=umdh.path, umdh_identity=dict(umdh.identity), + run_nonce=run_nonce, warmup=warmup, iterations=iterations, + windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs, + ), + )) + return await self._run(merge_graphs(*graphs)) diff --git a/tools/build/graphs/__init__.py b/tools/build/graphs/__init__.py new file mode 100644 index 0000000..7e6586f --- /dev/null +++ b/tools/build/graphs/__init__.py @@ -0,0 +1 @@ +"""Repository build graphs.""" diff --git a/tools/build/graphs/analysis.py b/tools/build/graphs/analysis.py new file mode 100644 index 0000000..b329695 --- /dev/null +++ b/tools/build/graphs/analysis.py @@ -0,0 +1,456 @@ +"""Compiler-derived, translation-unit-grained analysis graphs.""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from pathlib import Path +import xml.etree.ElementTree as ET + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.quality_tools import ResolvedTool, resolve_llvm +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.common import python_action, restore_node, tool_environment + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" +_PROJECT_PREFIX = "$(RepositoryRoot)" +_COMMON_INPUTS = ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", +) +_PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"} + + +def _relative(repository: Path, path: Path) -> str: + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def _platform(architecture: str) -> str: + try: + return _PLATFORMS[architecture] + except KeyError as error: + raise ValueError(f"unsupported architecture: {architecture}") from error + + +def _projects(repository: Path) -> tuple[tuple[str, Path, Path], ...]: + units = [] + for project in sorted((repository / "build/projects").glob("*.vcxproj")): + for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ClCompile"): + include = item.get("Include", "") + if not include: + continue + if not include.startswith(_PROJECT_PREFIX): + raise ValueError(f"unsupported ClCompile path in {project}: {include}") + relative = include.removeprefix(_PROJECT_PREFIX).replace("\\", "/") + units.append((project.stem, project, (repository / relative).resolve(strict=True))) + return tuple(units) + + +def _unit(repository: Path, source: Path) -> str: + return _relative(repository / "src", source).removesuffix(".cpp").replace("/", ".") + + +def dependency_node_name( + repository: Path, architecture: str, project_name: str, source: Path, + qualifier: str = "", +) -> str: + prefix = f"{architecture}-{qualifier}" if qualifier else architecture + return f"discover-dependencies-{prefix}-{project_name}-{_unit(repository, source)}" + + +def _project_files( + repository: Path, project_name: str, project: Path, source: Path, + extra: tuple[str, ...] = (), +) -> dict[str, bytes]: + inputs = list(_COMMON_INPUTS) + [_relative(repository, project), _relative(repository, source)] + if project_name.startswith("fuzz-"): + inputs.append("build/ObserverFuzz.props") + inputs.extend(extra) + return {path: (repository / path).read_bytes() for path in dict.fromkeys(inputs)} + + +def _compile_variables( + toolchain: MsvcToolchain, project: Path, source: Path, restore_output: Path, + configuration: str, platform: str, +) -> dict[str, object]: + return { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project), "target": "ClCompile", + "configuration": configuration, "platform": platform, "msbuild_args": [], + "source": str(source), "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + } + + +def _manifest_index(repository: Path, requested: set[str]) -> dict[str, bytes]: + paths, latest = BuildPaths(repository), {} + if not paths.cas_root.is_dir(): + return {} + for entry in paths.cas_root.glob("*-discover-dependencies-*"): + name = entry.name[33:] + if name not in requested: + continue + try: + cas = paths.cas(entry.name[:32], name) + except ValueError: + continue + manifest = cas.output / "dependencies.json" + if cas.touch.is_file() and not cas.touch.stat().st_size and manifest.is_file(): + candidate = (cas.touch.stat().st_mtime_ns, entry.name, manifest) + if name not in latest or candidate[:2] > latest[name][:2]: + latest[name] = candidate + return {name: candidate[2].read_bytes() for name, candidate in latest.items()} + + +def dependency_inputs( + repository: Path, restore_output: Path, source: Path, content: bytes +) -> dict[str, bytes]: + """Validate one MSVC manifest and sign only project/package dependency bytes.""" + + try: + data = json.loads(content)["Data"] + reported_source, includes = data["Source"], data["Includes"] + if not isinstance(reported_source, str) or not isinstance(includes, list) or not all( + isinstance(item, str) for item in includes + ): + raise TypeError + reported = Path(reported_source).resolve(strict=True) + dependencies = [Path(item) for item in includes] + if not all(path.is_absolute() for path in dependencies): + raise TypeError + except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, OSError) as error: + raise ValueError(f"invalid MSVC dependency manifest for {source}") from error + expected = source.resolve(strict=True) + if reported != expected: + raise ValueError(f"MSVC dependency manifest source mismatch for {source}") + + files = {"compiler/dependencies.json": content} + try: + for candidate in dict.fromkeys((expected, *dependencies)): + if candidate.is_relative_to(repository / "src"): + path = candidate.resolve(strict=True) + name = _relative(repository, path) + elif candidate.is_relative_to(restore_output): + path = candidate.resolve(strict=True) + name = "vcpkg/" + path.relative_to(restore_output).as_posix() + else: + continue + files[name] = path.read_bytes() + except OSError as error: + raise ValueError(f"invalid MSVC dependency manifest for {source}") from error + return files + + +def _dependency_node( + repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, restore: Node, + unit: tuple[str, Path, Path], namespace: str, architecture: str, platform: str, + configuration: str | None, qualifier: str, previous: Mapping[str, bytes], +) -> Node: + project_name, project, source = unit + name = dependency_node_name( + repository, architecture, project_name, source, qualifier + ) + restore_output = BuildPaths(repository).cas(restore.uid, restore.name).output + files = _project_files(repository, project_name, project, source) + prior = previous.get(name) + if prior is not None: + files.update(dependency_inputs(repository, restore_output, source, prior)) + variables = _compile_variables( + toolchain, project, source, restore_output, + configuration or ("Release" if project_name == "leak-probe" else "Debug"), + platform, + ) + return factory.make( + "source-dependencies.ps1", + name, + "slot", + variables, + files=files, + dependencies=(restore,), + config={"architecture": architecture, "source_namespace": namespace}, + ) + + +def dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, + project_names: tuple[str, ...] | None = None, configuration: str | None = None, + restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Create cacheable per-TU MSVC ``/sourceDependencies`` nodes.""" + + root = repository.resolve(strict=True) + factory = NodeFactory(TemplateRenderer(_BUILD_ROOT / "templates"), root, + dict(toolchain.identity), tool_environment(toolchain)) + namespace = "\n".join( + _relative(root, path) + for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) + ) + projects = tuple( + unit for unit in _projects(root) if project_names is None or unit[0] in project_names + ) + requested = { + dependency_node_name(root, architecture, unit[0], unit[2], name_qualifier) + for architecture in architectures + for unit in projects + if unit[0] != "leak-probe" or architecture == "x64" + } + previous = _manifest_index(root, requested) + nodes, targets = [], [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node(root, toolchain, factory, architecture, flavor=restore_flavor) + nodes.append(restore) + for unit in projects: + project_name = unit[0] + if project_name == "leak-probe" and architecture != "x64": + continue + node = _dependency_node( + root, toolchain, factory, restore, unit, namespace, architecture, + platform, configuration, name_qualifier, previous, + ) + nodes.append(node) + targets.append(node.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + +def _exact_identity(prefix: str, tool: ResolvedTool) -> dict[str, str]: + return {f"{prefix}.{key}": value for key, value in tool.identity} + + +def clang_dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, + project_names: tuple[str, ...] | None = None, configuration: str, + restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Capture actual clang-cl commands and resolve their exact header graph.""" + + root = repository.resolve(strict=True) + renderer = TemplateRenderer(_BUILD_ROOT / "templates") + environment = tool_environment(toolchain) + base_identity = dict(toolchain.identity) + clang = resolve_llvm(toolchain, "clang-cl") + scanner = resolve_llvm(toolchain, "clang-scan-deps") + clang_factory = NodeFactory( + renderer, + root, + base_identity | _exact_identity("clang_cl", clang), + environment, + ) + scan_factory = NodeFactory(renderer, root, base_identity, environment) + namespace = "\n".join( + _relative(root, path) + for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) + ) + projects = tuple( + unit for unit in _projects(root) if project_names is None or unit[0] in project_names + ) + requested = { + dependency_node_name(root, architecture, unit[0], unit[2], name_qualifier) + for architecture in architectures + for unit in projects + if unit[0] != "leak-probe" or architecture == "x64" + } + previous = _manifest_index(root, requested) + paths = BuildPaths(root) + nodes: list[Node] = [] + targets: list[str] = [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node( + root, toolchain, clang_factory, architecture, flavor=restore_flavor + ) + nodes.append(restore) + restore_output = paths.cas(restore.uid, restore.name).output + for project_name, project, source in projects: + if project_name == "leak-probe" and architecture != "x64": + continue + name = dependency_node_name( + root, architecture, project_name, source, name_qualifier + ) + files = _project_files(root, project_name, project, source) + prior = previous.get(name) + if prior is not None: + files.update(dependency_inputs(root, restore_output, source, prior)) + capture = clang_factory.make( + "clang-command.ps1", + name.replace("discover-dependencies-", "capture-clang-command-", 1), + "slot", + _compile_variables( + toolchain, + project, + source, + restore_output, + configuration, + platform, + ) | {"llvm_dir": str(toolchain.llvm_dir)}, + files=files, + dependencies=(restore,), + config={"architecture": architecture, "source_namespace": namespace}, + ) + command_file = paths.cas(capture.uid, capture.name).output / "compile-command.json" + scan = python_action( + scan_factory, + name, + "core.clang_dependencies", + ( + "scan", + str(source), + str(command_file), + str(scanner.path), + str(clang.path), + ), + (capture,), + pool="slot", + environment=environment, + identity=_exact_identity("clang_scan_deps", scanner), + config={"architecture": architecture, "source_namespace": namespace}, + ) + nodes.extend((capture, scan)) + targets.append(scan.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + +def analysis_discovery_slice( + repository: Path, toolchain: MsvcToolchain, jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + return dependency_discovery_slice( + repository, toolchain, jobs=jobs, architectures=architectures + ) + + +def load_dependency_manifests(repository: Path, discovery: Graph) -> dict[str, bytes]: + """Load manifests only from completed discovery nodes' exact CAS entries.""" + + paths, manifests = BuildPaths(repository.resolve(strict=True)), {} + for name in discovery.targets: + node = discovery.node(name) + cas = paths.cas(node.uid, node.name) + manifest = cas.output / "dependencies.json" + if not cas.touch.is_file() or cas.touch.stat().st_size or not manifest.is_file(): + raise FileNotFoundError(f"dependency discovery is incomplete: {name}") + manifests[name] = manifest.read_bytes() + return manifests + + +def _raw( + repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, discovery: Node, + restore_output: Path, unit: tuple[str, Path, Path], backend: str, + dependencies: Mapping[str, bytes], + architecture: str, platform: str, +) -> Node: + project_name, project, source = unit + slug, template, extra = { + "msvc": ("msvc", "msvc-analyze.ps1", ("build/ObserverNativeAnalysis.ruleset",)), + "clang-tidy": ("tidy", "clang-tidy.ps1", (".clang-tidy",)), + }[backend] + files = _project_files(repository, project_name, project, source, extra) + files.update(dependencies) + variables = _compile_variables( + toolchain, project, source, restore_output, + "Release" if project_name == "leak-probe" else "Debug", + platform, + ) | {"project_name": project_name, "llvm_dir": str(toolchain.llvm_dir)} + return factory.make( + template, + f"analyze-{slug}-{architecture}-{project_name}-{_unit(repository, source)}", + "slot", + variables, + files=files, + dependencies=(discovery,), + config={"architecture": architecture, "backend": backend}, + ) + + +def analysis_slice( + repository: Path, toolchain: MsvcToolchain, *, discovery: Graph, + manifests: Mapping[str, bytes], jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Analyze compiler-discovered TU inputs, normalize, merge, and gate.""" + + root = repository.resolve(strict=True) + factory = NodeFactory(TemplateRenderer(_BUILD_ROOT / "templates"), root, + dict(toolchain.identity), tool_environment(toolchain)) + paths = BuildPaths(root) + + def output(node: Node) -> Path: + return paths.cas(node.uid, node.name).output + + nodes, targets, projects = list(discovery.nodes), [], _projects(root) + for architecture in architectures: + platform = _platform(architecture) + restore = discovery.node(f"restore-vcpkg-{architecture}") + normalized = [] + for unit in projects: + project_name, _project, source = unit + if project_name == "leak-probe" and architecture != "x64": + continue + unit_name = _unit(root, source) + suffix = f"{architecture}-{project_name}-{unit_name}" + discovery_name = dependency_node_name(root, architecture, project_name, source) + discovered = discovery.node(discovery_name) + try: + manifest = manifests[discovery_name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {discovery_name}") from error + dependencies = dependency_inputs(root, output(restore), source, manifest) + raw_nodes = tuple( + (backend, _raw( + root, toolchain, factory, discovered, output(restore), unit, backend, + dependencies, architecture, platform, + )) + for backend in ("msvc", "clang-tidy") + ) + nodes.extend(raw for _backend, raw in raw_nodes) + for backend, raw in raw_nodes: + action = "normalize-msvc" if backend == "msvc" else "convert-tidy" + source_input = output(raw) / ( + f"{project_name}.sarif" if backend == "msvc" else "obj" + ) + automation = ( + f"{'msvc-analyze' if backend == 'msvc' else 'clang-tidy'}/" + f"{architecture}/{project_name}/{unit_name}/" + ) + arguments = ( + (action, str(source_input), automation) + if backend == "msvc" + else (action, str(root), str(source_input), automation) + ) + normal = python_action( + factory, + f"normalize-{'msvc' if backend == 'msvc' else 'tidy'}-{suffix}", + "core.sarif", + arguments, + (raw,), + pool="misc", + ) + nodes.append(normal) + normalized.append(normal) + merged = python_action( + factory, + f"merge-analysis-{architecture}", + "core.sarif", + ("merge", *(str(output(node) / "renpy.sarif") for node in normalized)), + tuple(normalized), + pool="misc", + ) + gate = python_action( + factory, + f"analysis-{architecture}", + "core.sarif", + ("gate", str(output(merged) / "analysis.sarif")), + (merged,), + pool="misc", + ) + nodes.extend((merged, gate)) + targets.append(gate.name) + return Graph(tuple(nodes), tuple(targets), {"misc": jobs, "restore": 1, "slot": jobs}) diff --git a/tools/build/graphs/audit.py b/tools/build/graphs/audit.py new file mode 100644 index 0000000..bb70ca1 --- /dev/null +++ b/tools/build/graphs/audit.py @@ -0,0 +1,115 @@ +"""Fine-grained PE and BinSkim audit nodes over explicit release artifacts.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +import re + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from graphs.common import BUILD_ROOT, extend_pools, produced_path, python_action, require_positive_integers, require_tool + + +_ARCHITECTURES = {"x86", "x64", "arm64"} +_MODULE = re.compile(r"[a-z0-9][a-z0-9._-]*\Z") + + +@dataclass(frozen=True, slots=True) +class BinaryArtifact: + architecture: str + module: str + producer: Node + path: Path + + +def audit_graph( + repository: Path, + upstream: Graph, + artifacts: Iterable[BinaryArtifact], + *, + dumpbin: Path, + dumpbin_identity: Mapping[str, str], + binskim: Path, + binskim_identity: Mapping[str, str], + jobs: int = 2, + binskim_jobs: int = 1, +) -> Graph: + """Compose independent release-binary audits over a validated upstream graph.""" + + require_positive_integers((jobs, binskim_jobs), "audit pool capacities must be positive integers") + root = repository.resolve(strict=True) + paths = BuildPaths(root) + dumpbin = require_tool(dumpbin, "dumpbin") + binskim = require_tool(binskim, "BinSkim") + renderer = TemplateRenderer(BUILD_ROOT / "templates") + dump_factory = NodeFactory(renderer, root, dict(dumpbin_identity) | {"path": str(dumpbin)}) + python_factory = NodeFactory(renderer, BUILD_ROOT, {}) + audit_nodes: list[Node] = [] + targets: list[str] = [] + seen: set[tuple[str, str]] = set() + + for artifact in sorted(tuple(artifacts), key=lambda item: (item.architecture, item.module)): + key = (artifact.architecture, artifact.module) + if key in seen: + raise ValueError(f"duplicate binary artifact: {key}") + seen.add(key) + if artifact.architecture not in _ARCHITECTURES or _MODULE.fullmatch(artifact.module) is None: + raise ValueError(f"invalid binary artifact identity: {key}") + binary = produced_path( + paths, upstream, artifact.producer, artifact.path, + f"binary artifact must be below its exact producer CAS: {artifact.path}", + ) + + dump_nodes = [] + for mode in ("headers", "dependents", "exports"): + current = dump_factory.make( + "argv.json", + f"audit-dumpbin-{mode}-{artifact.architecture}-{artifact.module}", + "dumpbin", + {"argv": (str(dumpbin), f"/{mode}", str(binary))}, + files={}, + dependencies=(artifact.producer,), + config={"action": mode, "architecture": artifact.architecture, "module": artifact.module}, + ) + dump_nodes.append(current) + pe_gate = python_action( + python_factory, + f"audit-pe-{artifact.architecture}-{artifact.module}", + "core.binary_audit", + ( + "pe", artifact.architecture, + *(str(paths.cas(node.uid, node.name).log) for node in dump_nodes), + ), + tuple(dump_nodes), + pool="audit", + ) + binskim_run = python_action( + python_factory, + f"audit-binskim-run-{artifact.architecture}-{artifact.module}", + "core.binary_audit", + ("run-binskim", str(binskim), str(binary)), + (artifact.producer,), + pool="binskim", + identity=dict(binskim_identity) | {"path": str(binskim)}, + config={"action": "run-binskim", "architecture": artifact.architecture, "module": artifact.module}, + ) + report = paths.cas(binskim_run.uid, binskim_run.name).output / "binskim.sarif" + binskim_gate = python_action( + python_factory, + f"audit-binskim-{artifact.architecture}-{artifact.module}", + "core.binary_audit", + ("binskim", str(report)), + (binskim_run,), + pool="audit", + ) + audit_nodes.extend((*dump_nodes, pe_gate, binskim_run, binskim_gate)) + targets.extend((pe_gate.name, binskim_gate.name)) + + if not seen: + raise ValueError("audit graph requires at least one binary artifact") + pools = extend_pools(upstream, {"audit": jobs, "binskim": binskim_jobs, "dumpbin": jobs}) + return Graph(upstream.nodes + tuple(audit_nodes), tuple(targets), pools) diff --git a/tools/build/graphs/common.py b/tools/build/graphs/common.py new file mode 100644 index 0000000..f5099b0 --- /dev/null +++ b/tools/build/graphs/common.py @@ -0,0 +1,179 @@ +"""Shared recipe plumbing for repository build graphs.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import hashlib +import os +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +PROJECTS = ("renpy", "rpgmaker", "zanzarah", "tests") +BINARIES = {**{name: f"{name}.so" for name in PROJECTS[:-1]}, "tests": "tests.exe"} +PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"} +_MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" +_PROJECT_PREFIX = "$(RepositoryRoot)" +COMMON_PROJECT_INPUTS = ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", +) + + +def require_positive_integers(values: Iterable[object], message: str) -> None: + if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in values): + raise ValueError(message) + + +def require_tool(path: Path, name: str) -> Path: + resolved = path.resolve(strict=True) + if not resolved.is_file(): + raise FileNotFoundError(f"{name} is not a file: {resolved}") + return resolved + + +def prefixed_identity(prefix: str, path: Path, values: Mapping[str, str]) -> dict[str, str]: + return {f"{prefix}.{key}": value for key, value in values.items()} | { + f"{prefix}.path": str(path) + } + + +def extend_pools(upstream: Graph, additions: Mapping[str, int]) -> dict[str, int]: + pools = dict(upstream.pools) + for name, capacity in additions.items(): + if name in pools and pools[name] != capacity: + raise ValueError(f"conflicting pool capacity for {name}") + pools[name] = capacity + return pools + + +def produced_path(paths: BuildPaths, upstream: Graph, producer: Node, candidate: Path, message: str) -> Path: + try: + if upstream.node(producer.name) != producer: + raise ValueError("producer does not match upstream") + output = paths.cas(producer.uid, producer.name).output + current = paths.require_confined(candidate, paths.cas_root) + except ValueError as error: + raise ValueError(message) from error + if current == output or not current.is_relative_to(output): + raise ValueError(message) + return current + + +def require_ancestor(upstream: Graph, producer: Node, ancestor: str, message: str) -> None: + pending, seen = list(producer.inputs), set() + while pending: + name = pending.pop() + if name == ancestor: + return + if name not in seen: + seen.add(name) + pending.extend(upstream.node(name).inputs) + raise ValueError(message) + + +def required_audit_gates(upstream: Graph, producer: Node, architecture: str, module: str) -> tuple[Node, Node]: + try: + gates = tuple(upstream.node(f"audit-{kind}-{architecture}-{module}") for kind in ("pe", "binskim")) + except ValueError as error: + raise ValueError(f"missing audit gates for {architecture}/{module}") from error + for gate in gates: + require_ancestor( + upstream, gate, producer.name, + f"audit gates do not consume producer for {architecture}/{module}", + ) + return gates + + +def project_path(repository: Path, value: str, project: Path) -> str: + if not value.startswith(_PROJECT_PREFIX): + raise ValueError(f"unsupported project input in {project}: {value}") + path = repository / value.removeprefix(_PROJECT_PREFIX).replace("\\", "/") + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def project_inputs(repository: Path, project: Path) -> tuple[str, ...]: + inputs = list(COMMON_PROJECT_INPUTS) + [project.relative_to(repository).as_posix()] + for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ModuleDefinitionFile"): + if item.text: + inputs.append(project_path(repository, item.text.strip(), project)) + return tuple(dict.fromkeys(inputs)) + + +def project_sources(repository: Path, project: Path) -> tuple[Path, ...]: + return tuple( + repository / project_path(repository, item.get("Include", ""), project) + for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ClCompile") + if item.get("Include") + ) + + +def tool_environment(toolchain: object, *, prepend_path: Path | None = None, + extra: tuple[tuple[str, str], ...] = ()) -> tuple[tuple[str, str], ...]: + values = {key.casefold(): (key, value) for key, value in toolchain.environment} + if hasattr(toolchain, "vcpkg_root"): + values["vcpkg_root"] = ("VCPKG_ROOT", str(toolchain.vcpkg_root)) + if prepend_path is not None: + current = values.get("path", ("PATH", ""))[1] + values["path"] = ("PATH", str(prepend_path) + (os.pathsep + current if current else "")) + values.update((key.casefold(), (key, value)) for key, value in extra) + return tuple(values.values()) + + +def _restore_identity(toolchain: object, vcpkg: Path) -> dict[str, str]: + identity = { + "pwsh": str(toolchain.pwsh), + "vcpkg": str(vcpkg), + "vcpkg_root": str(toolchain.vcpkg_root), + } + for name, path in (("pwsh", Path(toolchain.pwsh)), ("vcpkg", vcpkg)): + if path.is_file(): + with path.open("rb") as stream: + identity[f"{name}_sha256"] = hashlib.file_digest(stream, "sha256").hexdigest() + return identity + + +def restore_node(repository: Path, toolchain: object, factory: NodeFactory, architecture: str, + *, flavor: str = "") -> Node: + qualifier = f"-{flavor}" if flavor else "" + triplet = f"observer-{architecture}-windows-static{qualifier}" + inputs = ("vcpkg.json", f"build/vcpkg/triplets/{triplet}.cmake") + vcpkg = Path(toolchain.vcpkg_root) / "vcpkg.exe" + return factory.make( + "vcpkg.ps1", f"restore-vcpkg{qualifier}-{architecture}", "restore", { + "pwsh": str(toolchain.pwsh), + "vcpkg": str(vcpkg), + "repository": str(repository), + "triplet": triplet, + }, + files={path: (repository / path).read_bytes() for path in inputs}, + config={"action": "restore", "architecture": architecture, "triplet": triplet}, + identity=_restore_identity(toolchain, vcpkg), + environment=tuple(sorted((key.upper(), value) for key, value in tool_environment(toolchain))), + cwd=repository, + ) + + +def python_action(factory: NodeFactory, name: str, module: str, arguments: tuple[str, ...], + dependencies: tuple[Node, ...], *, pool: str, + environment: tuple[tuple[str, str], ...] = (), files: Mapping[str, bytes] | None = None, + identity: Mapping[str, str] | None = None, + config: Mapping[str, str] | None = None) -> Node: + executable = str(Path(sys.executable).resolve()) + source = BUILD_ROOT.joinpath(*module.split(".")).with_suffix(".py") + return factory.make( + "argv.json", name, pool, {"argv": (executable, "-m", module) + arguments}, + files={source.relative_to(BUILD_ROOT.parent.parent).as_posix(): source.read_bytes()} | dict(files or {}), + dependencies=dependencies, + identity=dict(identity or {}) | {"python": sys.version, "python_executable": executable}, + config={"action": arguments[0], "platform": "windows"} | dict(config or {}), + environment=environment, + cwd=BUILD_ROOT, + ) diff --git a/tools/build/graphs/coverage.py b/tools/build/graphs/coverage.py new file mode 100644 index 0000000..b57b240 --- /dev/null +++ b/tools/build/graphs/coverage.py @@ -0,0 +1,303 @@ +"""Fine-grained LLVM source coverage over explicit instrumented build artifacts.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.quality_tools import resolve_llvm, resolve_tool +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.common import ( + BINARIES, + BUILD_ROOT, + extend_pools, + prefixed_identity, + produced_path, + python_action, + require_ancestor, + require_positive_integers, + require_tool, + tool_environment, +) +from graphs.instrumented import ( + InstrumentedArtifact, + InstrumentedVariant, + instrumented_build_slice, + instrumented_dependency_discovery_slice, +) + + +_ARCHITECTURES = {"x86", "x64", "arm64"} +_IGNORED_SOURCES = ( + r"([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|" + r"([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])" +) + + +@dataclass(frozen=True, slots=True) +class CoverageArtifact: + architecture: str + name: str + producer: Node + path: Path + + +def _artifacts( + paths: BuildPaths, + upstream: Graph, + artifacts: Iterable[CoverageArtifact | InstrumentedArtifact], +) -> dict[str, dict[str, CoverageArtifact | InstrumentedArtifact]]: + selected: dict[str, dict[str, CoverageArtifact | InstrumentedArtifact]] = {} + for artifact in artifacts: + key = (artifact.architecture, artifact.name) + if artifact.architecture not in _ARCHITECTURES or artifact.name not in BINARIES: + raise ValueError(f"invalid coverage artifact identity: {key}") + group = selected.setdefault(artifact.architecture, {}) + if artifact.name in group: + raise ValueError(f"duplicate coverage artifact: {key}") + expected_producer = f"build-{artifact.name}-{artifact.architecture}-coverage" + message = f"coverage artifact must use its exact producer CAS: {artifact.path}" + if artifact.producer.name != expected_producer: + raise ValueError(message) + binary = produced_path(paths, upstream, artifact.producer, artifact.path, message) + producer_output = paths.cas(artifact.producer.uid, artifact.producer.name).output + if binary != producer_output / BINARIES[artifact.name]: + raise ValueError(f"coverage artifact must use its exact producer CAS: {binary}") + restore = f"restore-vcpkg-{artifact.architecture}" + require_ancestor( + upstream, artifact.producer, restore, + f"coverage build requires {restore} as a restore ancestor", + ) + group[artifact.name] = artifact + if not selected: + raise ValueError("coverage graph requires at least one complete artifact set") + for architecture, group in selected.items(): + if group.keys() != BINARIES.keys(): + raise ValueError(f"coverage {architecture} requires the complete artifact set") + return selected + + +def coverage_artifact_graph( + repository: Path, + upstream: Graph, + artifacts: Iterable[CoverageArtifact | InstrumentedArtifact], + *, + pwsh: Path, + pwsh_identity: Mapping[str, str], + llvm_profdata: Path, + llvm_profdata_identity: Mapping[str, str], + llvm_cov: Path, + llvm_cov_identity: Mapping[str, str], + environment: tuple[tuple[str, str], ...] = (), + test_shards: int = 4, + jobs: int = 4, + report_jobs: int = 2, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Compose shard, merge, report, and immutable 100% gates per runnable arch.""" + + require_positive_integers( + (test_shards, jobs, report_jobs), + "coverage counts and pool capacities must be positive integers", + ) + corpus_path = None + if corpus is not None: + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("corpus run nonce must be a non-empty string without NUL") + corpus_path = Path(corpus).resolve(strict=True) + if not corpus_path.is_dir(): + raise NotADirectoryError(corpus_path) + root = repository.resolve(strict=True) + paths = BuildPaths(root) + pwsh = require_tool(pwsh, "PowerShell") + llvm_profdata = require_tool(llvm_profdata, "llvm-profdata") + llvm_cov = require_tool(llvm_cov, "llvm-cov") + selected = _artifacts(paths, upstream, artifacts) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) + shard_factory = NodeFactory(renderer, root, pwsh_id, environment) + merge_factory = NodeFactory( + renderer, root, + pwsh_id | prefixed_identity("llvm_profdata", llvm_profdata, llvm_profdata_identity), + environment, + ) + report_factory = NodeFactory( + renderer, root, + pwsh_id | prefixed_identity("llvm_cov", llvm_cov, llvm_cov_identity), + environment, + ) + gate_factory = NodeFactory(renderer, BUILD_ROOT, {}) + nodes: list[Node] = [] + targets: list[str] = [] + + def output(node: Node, filename: str = "") -> Path: + return paths.cas(node.uid, node.name).output / filename + + for architecture, group in sorted(selected.items()): + builds = tuple(group[name].producer for name in BINARIES) + copies = tuple( + {"name": BINARIES[name], "source": str(group[name].path)} for name in BINARIES + ) + shards = tuple( + shard_factory.make( + "coverage-test.ps1", + f"coverage-test-{architecture}-{index}", + "coverage-shard", + { + "pwsh": str(pwsh), "artifacts": copies, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + config={ + "action": "coverage-test", "architecture": architecture, + "shard_count": str(test_shards), "shard_index": str(index), + }, + ) + for index in range(test_shards) + ) + corpus_shards: tuple[Node, ...] = () + if corpus_path is not None: + corpus_environment = tuple( + pair for pair in environment + if pair[0].casefold() != "observer_test_corpus" + ) + (("OBSERVER_TEST_CORPUS", str(corpus_path)),) + corpus_shards = tuple( + shard_factory.make( + "coverage-corpus-test.ps1", + f"coverage-corpus-{architecture}-{index}", + "coverage-shard", + { + "pwsh": str(pwsh), "artifacts": copies, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + config={ + "action": "coverage-corpus-test", + "architecture": architecture, + "shard_count": str(test_shards), + "shard_index": str(index), + "corpus": str(corpus_path), + "run_nonce": run_nonce, + }, + environment=corpus_environment, + ) + for index in range(test_shards) + ) + profile_shards = shards + corpus_shards + merge = merge_factory.make( + "coverage-merge.ps1", + f"coverage-merge-{architecture}", + "coverage-merge", + { + "pwsh": str(pwsh), "llvm_profdata": str(llvm_profdata), + "profile_directories": tuple(str(output(shard)) for shard in profile_shards), + }, + files={}, dependencies=profile_shards, + config={"action": "coverage-merge", "architecture": architecture}, + ) + profile = output(merge, "coverage.profdata") + reports = [] + for kind, summary_only in (("json", True), ("lcov", False)): + report = report_factory.make( + "coverage-report.ps1", + f"coverage-{kind}-{architecture}", + "coverage-report", + { + "pwsh": str(pwsh), "llvm_cov": str(llvm_cov), + "test_executable": str(group["tests"].path), + "profile": str(profile), "ignore_regex": _IGNORED_SOURCES, + "objects": tuple(str(group[name].path) for name in BINARIES if name != "tests"), + "summary_only": summary_only, "report_name": f"coverage.{kind}", + }, + files={}, dependencies=(merge, *builds), + config={"action": f"coverage-{kind}", "architecture": architecture}, + ) + reports.append(report) + gate = python_action( + gate_factory, + f"coverage-{architecture}", + "core.cpp_coverage", + ("gate", str(output(reports[0], "coverage.json"))), + tuple(reports), + pool="coverage-gate", + ) + nodes.extend((*profile_shards, merge, *reports, gate)) + targets.append(gate.name) + + pools = extend_pools( + upstream, + { + "coverage-shard": jobs, + "coverage-merge": len(selected), + "coverage-report": report_jobs, + "coverage-gate": jobs, + }, + ) + return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools) + + +def coverage_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Discover exact inputs for every requested Coverage build.""" + + variants = tuple(InstrumentedVariant("coverage", item) for item in architectures) + return instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants, jobs=jobs + ) + + +def coverage_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + jobs: int = 4, + architectures: tuple[str, ...] = ("x64",), + test_shards: int = 4, + report_jobs: int = 2, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Build instrumented C++ artifacts, run shards, report, and gate coverage.""" + + variants = tuple(InstrumentedVariant("coverage", item) for item in architectures) + upstream, produced = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + jobs=jobs, + ) + pwsh = resolve_tool(toolchain.pwsh, "PowerShell") + profdata = resolve_llvm(toolchain, "llvm-profdata") + cov = resolve_llvm(toolchain, "llvm-cov") + return coverage_artifact_graph( + repository, + upstream, + produced, + pwsh=pwsh.path, + pwsh_identity=dict(pwsh.identity), + llvm_profdata=profdata.path, + llvm_profdata_identity=dict(profdata.identity), + llvm_cov=cov.path, + llvm_cov_identity=dict(cov.identity), + environment=tool_environment(toolchain), + test_shards=test_shards, + jobs=jobs, + report_jobs=report_jobs, + corpus=corpus, + run_nonce=run_nonce, + ) diff --git a/tools/build/graphs/fuzz.py b/tools/build/graphs/fuzz.py new file mode 100644 index 0000000..8ea6de4 --- /dev/null +++ b/tools/build/graphs/fuzz.py @@ -0,0 +1,272 @@ +"""Independent build, seed-replay, and bounded-run branches for every fuzzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +import re +import xml.etree.ElementTree as ET + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + clang_dependency_discovery_slice, + dependency_inputs, + dependency_node_name, +) +from graphs.common import tool_environment + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" +_PREFIX = "$(RepositoryRoot)" +_TARGETS = {"pickle": 262144, "renpy": 1048576, "rpgmaker": 1048576, "zanzarah": 1048576} +FUZZ_TARGETS = tuple(_TARGETS) +_COMMON = ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/ObserverFuzz.props", +) +_ASAN_OPTIONS = "halt_on_error=1:alloc_dealloc_mismatch=1" +_UID = re.compile(r"[0-9a-f]{32}") + + +@dataclass(frozen=True, slots=True) +class FuzzCorpusArtifact: + """Reference one successfully published timed-run corpus by canonical CAS UID.""" + + target: str + producer_uid: str + + def __post_init__(self) -> None: + if self.target not in _TARGETS: + raise ValueError(f"unsupported fuzz corpus target: {self.target}") + if not isinstance(self.producer_uid, str) or not _UID.fullmatch(self.producer_uid): + raise ValueError("fuzz corpus producer UID must be a canonical MD5") + + +def fuzz_corpus_artifacts(graph: Graph) -> tuple[FuzzCorpusArtifact, ...]: + return tuple( + FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid) + for target in FUZZ_TARGETS if f"fuzz-x64-{target}" in graph.targets + ) + + +def _selected_targets(targets: tuple[str, ...]) -> tuple[str, ...]: + if not targets: + raise ValueError("fuzz targets must not be empty") + selected = set() + for target in targets: + if target not in _TARGETS: + raise ValueError(f"unsupported fuzz target: {target}") + if target in selected: + raise ValueError(f"duplicate fuzz target: {target}") + selected.add(target) + return targets + + +def _runtime(toolchain: MsvcToolchain) -> Path: + candidates = sorted(toolchain.llvm_dir.glob("lib/clang/*/lib/windows"), reverse=True) + if not candidates or not candidates[0].is_dir(): + raise FileNotFoundError(f"LLVM sanitizer runtimes not found below {toolchain.llvm_dir}") + return candidates[0].resolve() + + +def fuzz_dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, jobs: int = 2, + targets: tuple[str, ...] = FUZZ_TARGETS, +) -> Graph: + """Discover Fuzz-configuration dependencies against the ASan triplet.""" + + return clang_dependency_discovery_slice( + repository, toolchain, + project_names=tuple(f"fuzz-{target}" for target in _selected_targets(targets)), + configuration="Fuzz", restore_flavor="asan", name_qualifier="fuzz", jobs=jobs, + ) + + +def _project_files( + repository: Path, target: str, restore_output: Path, discovery: Graph, + manifests: Mapping[str, bytes], +) -> tuple[dict[str, bytes], tuple[Node, ...]]: + project = repository / f"build/projects/fuzz-{target}.vcxproj" + paths = [repository / relative for relative in _COMMON] + [project] + files = {} + dependencies = [] + for item in ET.parse(project).getroot().iter(f"{_NS}ClCompile"): + include = item.get("Include", "") + if not include.startswith(_PREFIX): + raise ValueError(f"unsupported ClCompile path in {project}: {include}") + source = (repository / include.removeprefix(_PREFIX)).resolve(strict=True) + name = dependency_node_name(repository, "x64", f"fuzz-{target}", source, "fuzz") + dependencies.append(discovery.node(name)) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs(repository, restore_output, source, manifest)) + relative = (path.resolve(strict=True).relative_to(repository).as_posix() for path in paths) + files.update({name: (repository / name).read_bytes() for name in dict.fromkeys(relative)}) + return files, tuple(dependencies) + + +def _seed_files(repository: Path, target: str) -> tuple[Path, dict[str, bytes]]: + directory = repository / "src/fuzz/corpus" / target + seeds = tuple(sorted(path for path in directory.iterdir() if path.is_file())) + if not seeds: + raise FileNotFoundError(f"no checked-in fuzzer seeds for {target}") + return directory, { + path.relative_to(repository).as_posix(): path.read_bytes() for path in seeds + } + + +def _prior_corpora( + repository: Path, artifacts: tuple[FuzzCorpusArtifact, ...] +) -> dict[str, tuple[FuzzCorpusArtifact, Path, dict[str, bytes]]]: + indexed = {} + for artifact in artifacts: + if artifact.target in indexed: + raise ValueError(f"duplicate prior corpus: {artifact.target}") + indexed[artifact.target] = artifact + + paths, result = BuildPaths(repository), {} + for target, artifact in indexed.items(): + node_name = f"run-fuzz-x64-{target}" + cas = paths.cas(artifact.producer_uid, node_name) + corpus = paths.require_confined(cas.output / "corpus", paths.cas_root) + if not ( + cas.entry.is_dir() + and cas.output.is_dir() + and cas.log.is_file() + and cas.touch.is_file() + and cas.touch.stat().st_size == 0 + and corpus.is_dir() + ): + raise FileNotFoundError(f"prior fuzz corpus is not published: {target}") + files = {} + for path in sorted(corpus.iterdir()): + confined = paths.require_confined(path, paths.cas_root) + if not confined.is_file(): + raise ValueError(f"prior fuzz corpus contains a non-file: {confined}") + files[f"prior/{target}/{confined.name}"] = confined.read_bytes() + if not files: + raise ValueError(f"prior fuzz corpus is empty: {target}") + result[target] = (artifact, corpus, files) + return result + + +def fuzz_graph( + repository: Path, toolchain: MsvcToolchain, *, discovery: Graph | None, + manifests: Mapping[str, bytes], run_nonce: str, seconds: int = 60, + jobs: int = 2, fuzz_jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + prior_corpora: tuple[FuzzCorpusArtifact, ...] = (), + targets: tuple[str, ...] = FUZZ_TARGETS, +) -> Graph: + """Return the selected demand-independent x64 libFuzzer branches.""" + + if architectures != ("x64",): + raise ValueError("libFuzzer MSBuild contract is x64-only") + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("run nonce must be a non-empty string without NUL") + if isinstance(seconds, bool) or seconds <= 0: + raise ValueError("fuzz seconds must be positive") + selected_targets = _selected_targets(targets) + if discovery is None: + raise ValueError("fuzz dependency discovery is required") + root = repository.resolve(strict=True) + prior = _prior_corpora(root, prior_corpora) + runtime = _runtime(toolchain) + renderer = TemplateRenderer(_BUILD_ROOT / "templates") + paths = BuildPaths(root) + identity = dict(toolchain.identity) | {"llvm_runtime": str(runtime)} + factory = NodeFactory(renderer, root, identity, tool_environment(toolchain)) + + restore = discovery.node("restore-vcpkg-asan-x64") + nodes, targets = list(discovery.nodes), [] + restore_output = paths.cas(restore.uid, restore.name).output + + for target in selected_targets: + max_length = _TARGETS[target] + executable_name = f"fuzz-{target}.exe" + files, dependencies = _project_files( + root, target, restore_output, discovery, manifests + ) + build = factory.make( + "fuzz-build.ps1", + f"build-fuzz-x64-{target}", + "build", + { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(root / f"build/projects/fuzz-{target}.vcxproj"), + "target": "Build", "configuration": "Fuzz", "platform": "x64", + "vcpkg_root": str(toolchain.vcpkg_root), "vcpkg_installed": str(restore_output), + "llvm_dir": str(toolchain.llvm_dir), "llvm_runtime": str(runtime), + "executable_name": executable_name, + }, + files=files, dependencies=dependencies, + config={"action": "build", "architecture": "x64", "target": target}, + ) + seed_dir, seed_files = _seed_files(root, target) + fuzzer = paths.cas(build.uid, build.name).output / executable_name + replay = factory.make( + "fuzz-replay.ps1", + f"replay-fuzz-x64-{target}", + "fuzz", + { + "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer), + "seed_dir": str(seed_dir), "max_length": max_length, + }, + files=seed_files, dependencies=(build,), + config={"action": "replay", "target": target, "max_length": str(max_length), + "asan_options": _ASAN_OPTIONS}, + environment=tool_environment( + toolchain, + prepend_path=runtime, + extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),), + ), + ) + previous = prior.get(target) + prior_uid, prior_path, prior_files = ( + ("", "", {}) + if previous is None + else (previous[0].producer_uid, str(previous[1]), previous[2]) + ) + run = factory.make( + "fuzz-run.ps1", + f"run-fuzz-x64-{target}", + "fuzz", + { + "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer), "seed_dir": str(seed_dir), + "max_length": max_length, "seconds": seconds, "prior_corpus": prior_path, + }, + files=seed_files | prior_files, dependencies=(replay,), + config={"action": "fuzz", "target": target, "max_length": str(max_length), + "seconds": str(seconds), "run_nonce": run_nonce, + "asan_options": _ASAN_OPTIONS, "prior_corpus_uid": prior_uid}, + environment=tool_environment( + toolchain, + prepend_path=runtime, + extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),), + ), + ) + gate = factory.make( + "fuzz-gate.ps1", + f"fuzz-x64-{target}", + "fuzz", + {"pwsh": str(toolchain.pwsh), + "status": str(paths.cas(run.uid, run.name).output / "status.txt")}, + files={}, dependencies=(run,), + config={"action": "fuzz-gate", "target": target}, + ) + nodes.extend((build, replay, run, gate)) + targets.append(gate.name) + return Graph( + tuple(nodes), tuple(targets), + {"build": jobs, "fuzz": fuzz_jobs, "restore": 1, "slot": jobs}, + ) diff --git a/tools/build/graphs/instrumented.py b/tools/build/graphs/instrumented.py new file mode 100644 index 0000000..2a5edc8 --- /dev/null +++ b/tools/build/graphs/instrumented.py @@ -0,0 +1,254 @@ +"""Staged MSBuild producers for Coverage, ASan, and UBSan graphs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +from core.graph import Graph, GraphError, Node, merge_graphs +from core.node import NodeFactory +from core.paths import BuildPaths +from core.quality_tools import UBSAN_LIBRARIES, resolve_llvm +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + clang_dependency_discovery_slice, + dependency_discovery_slice, + dependency_inputs, + dependency_node_name, +) +from graphs.common import ( + BINARIES, + BUILD_ROOT, + PLATFORMS, + PROJECTS, + project_inputs, + project_sources, + require_positive_integers, + tool_environment, +) + + +_CONFIGURATIONS = {"coverage": "Coverage", "asan": "ASan", "ubsan": "UBSan"} + + +@dataclass(frozen=True, slots=True) +class InstrumentedVariant: + kind: str + architecture: str + llvm_runtime: Path | None = None + runtime_identity: Mapping[str, str] = field(default_factory=dict) + + @property + def configuration(self) -> str: + return _CONFIGURATIONS.get(self.kind, self.kind) + + +@dataclass(frozen=True, slots=True) +class InstrumentedArtifact: + kind: str + architecture: str + name: str + producer: Node + path: Path + + +def _variants( + variants: tuple[InstrumentedVariant, ...], jobs: int +) -> tuple[InstrumentedVariant, ...]: + require_positive_integers((jobs,), "jobs must be a positive integer") + if not variants: + raise ValueError("at least one instrumented variant is required") + keys = [(item.kind, item.architecture) for item in variants] + if len(keys) != len(set(keys)): + raise ValueError("duplicate instrumented variant") + for item in variants: + supported = ( + item.kind == "coverage" and item.architecture in PLATFORMS + or item.kind == "asan" and item.architecture in {"x86", "x64"} + or item.kind == "ubsan" and item.architecture == "x64" + ) + if not supported: + raise ValueError(f"unsupported instrumented variant: {(item.kind, item.architecture)}") + if item.kind == "ubsan": + runtime = item.llvm_runtime.resolve(strict=True) if item.llvm_runtime else None + if runtime is None or not runtime.is_dir() or not item.runtime_identity: + raise ValueError("UBSan runtime directory and exact identity are required") + for name in UBSAN_LIBRARIES: + if not (runtime / name).is_file(): + raise FileNotFoundError(f"UBSan runtime library not found: {runtime / name}") + elif item.llvm_runtime is not None or item.runtime_identity: + raise ValueError(f"LLVM runtime is invalid for {item.kind} variant") + return variants + + +def instrumented_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + variants: tuple[InstrumentedVariant, ...], + jobs: int = 2, +) -> Graph: + """Discover compiler inputs per instrumented configuration and TU.""" + + selected = _variants(variants, jobs) + graphs = tuple( + ( + clang_dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS, + configuration=item.configuration, + name_qualifier=item.kind, + jobs=jobs, + architectures=(item.architecture,), + ) + if item.kind in {"coverage", "ubsan"} + else dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS, + configuration=item.configuration, + restore_flavor="asan", + name_qualifier=item.kind, + jobs=jobs, + architectures=(item.architecture,), + ) + ) + for item in selected + ) + try: + return merge_graphs(*graphs) + except GraphError as error: + name = str(error).rsplit(": ", 1)[-1] + raise ValueError(f"conflicting canonical node: {name}") from error + + +def _build( + repository: Path, + toolchain: MsvcToolchain, + factory: NodeFactory, + discovery: Graph, + manifests: Mapping[str, bytes], + variant: InstrumentedVariant, + project_name: str, + clang_identity: Mapping[str, str], +) -> Node: + project = repository / "build/projects" / f"{project_name}.vcxproj" + files = { + name: (repository / name).read_bytes() + for name in project_inputs(repository, project) + } + dependencies = [] + restore_name = ( + f"restore-vcpkg-asan-{variant.architecture}" + if variant.kind == "asan" + else f"restore-vcpkg-{variant.architecture}" + ) + restore = discovery.node(restore_name) + restore_output = BuildPaths(repository).cas(restore.uid, restore.name).output + for source in project_sources(repository, project): + name = dependency_node_name( + repository, variant.architecture, project_name, source, variant.kind + ) + discovered = discovery.node(name) + dependencies.append(discovered) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs(repository, restore_output, source, manifest)) + + runtime = variant.llvm_runtime.resolve(strict=True) if variant.llvm_runtime else None + identity = dict(toolchain.identity) + if variant.kind in {"coverage", "ubsan"}: + identity.update(clang_identity) + if runtime is not None: + identity.update( + {f"llvm_runtime.{key}": value for key, value in variant.runtime_identity.items()} + ) + identity["llvm_runtime.path"] = str(runtime) + return factory.make( + "instrumented-build.ps1", + f"build-{project_name}-{variant.architecture}-{variant.kind}", + "slot", + { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project), "target": "Build", + "configuration": variant.configuration, + "platform": PLATFORMS[variant.architecture], + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + "llvm_dir": str(toolchain.llvm_dir) if variant.kind in {"coverage", "ubsan"} else "", + "llvm_runtime": str(runtime) if runtime else "", + }, + files=files, + dependencies=tuple(dependencies), + identity=identity, + config={ + "action": "build", "kind": variant.kind, + "architecture": variant.architecture, "project": project_name, + }, + ) + + +def instrumented_build_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + variants: tuple[InstrumentedVariant, ...], + jobs: int = 2, +) -> tuple[Graph, tuple[InstrumentedArtifact, ...]]: + """Build independently cacheable modules/tests from completed discovery.""" + + selected = _variants(variants, jobs) + if discovery is None: + raise ValueError("instrumented dependency discovery is required") + if discovery.pools.get("slot") != jobs: + raise ValueError("conflicting slot pool capacity") + root = repository.resolve(strict=True) + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), + root, + dict(toolchain.identity), + tool_environment(toolchain), + ) + paths = BuildPaths(root) + clang_identity: dict[str, str] = {} + if any(item.kind in {"coverage", "ubsan"} for item in selected): + clang = resolve_llvm(toolchain, "clang-cl") + clang_identity = { + f"clang_cl.{key}": value for key, value in clang.identity + } + builds, artifacts = [], [] + for variant in selected: + for project in PROJECTS: + build = _build( + root, + toolchain, + factory, + discovery, + manifests, + variant, + project, + clang_identity, + ) + builds.append(build) + artifacts.append( + InstrumentedArtifact( + variant.kind, + variant.architecture, + project, + build, + paths.cas(build.uid, build.name).output / BINARIES[project], + ) + ) + graph = Graph( + discovery.nodes + tuple(builds), + tuple(build.name for build in builds), + discovery.pools, + ) + return graph, tuple(artifacts) diff --git a/tools/build/graphs/leak.py b/tools/build/graphs/leak.py new file mode 100644 index 0000000..b57c549 --- /dev/null +++ b/tools/build/graphs/leak.py @@ -0,0 +1,132 @@ +"""Independent UMDH leak scenarios composed over x64 Release binaries.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from pathlib import Path + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from graphs.audit import BinaryArtifact +from graphs.common import ( + BUILD_ROOT, extend_pools, produced_path, python_action, required_audit_gates, + require_positive_integers, require_tool, +) + + +LEAK_MODES = ("operations", "lifecycle") +LEAK_SCENARIOS = ("small-success", "malformed", "cancellation", "read-failure", "write-failure", "large-metadata", "sparse-metadata") +_BINARIES = {"leak-probe": "leak-probe.exe", "renpy": "renpy.so", "rpgmaker": "rpgmaker.so", "zanzarah": "zanzarah.so"} + + +def _artifacts(paths: BuildPaths, upstream: Graph, artifacts: Iterable[BinaryArtifact]) -> dict[str, BinaryArtifact]: + selected: dict[str, BinaryArtifact] = {} + for artifact in artifacts: + if artifact.architecture != "x64" or artifact.module not in _BINARIES: + raise ValueError(f"invalid leak binary artifact: {(artifact.architecture, artifact.module)}") + if artifact.module in selected: + raise ValueError(f"duplicate leak binary artifact: {artifact.module}") + binary = produced_path( + paths, upstream, artifact.producer, artifact.path, + f"leak binary must be below its exact producer CAS: {artifact.path}", + ) + if binary.name != _BINARIES[artifact.module]: + raise ValueError(f"leak binary must be below its exact producer CAS: {binary}") + selected[artifact.module] = artifact + missing = _BINARIES.keys() - selected.keys() + if missing: + raise ValueError(f"missing leak binary artifacts: {', '.join(sorted(missing))}") + return selected + + +def leak_graph( + repository: Path, + upstream: Graph, + artifacts: Iterable[BinaryArtifact], + *, + umdh: Path, + umdh_identity: Mapping[str, str], + run_nonce: str, + warmup: int = 8, + iterations: int = 100, + windows: int = 3, + tolerance_bytes: int = 0, + jobs: int = 4, + session_jobs: int = 2, + diff_jobs: int = 4, +) -> Graph: + """Add one demand target per leak mode/scenario without a monolithic gate.""" + + capacities = (jobs, session_jobs, diff_jobs) + counts = (warmup, iterations, windows) + require_positive_integers(capacities, "leak pool capacities must be positive integers") + require_positive_integers(counts, "leak measurement counts must be positive integers") + if windows < 3: + raise ValueError("leak measurement requires at least three windows") + if isinstance(tolerance_bytes, bool) or not isinstance(tolerance_bytes, int) or tolerance_bytes < 0: + raise ValueError("leak tolerance must be a non-negative integer") + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("run nonce must be a non-empty string without NUL") + + root = repository.resolve(strict=True) + paths = BuildPaths(root) + umdh = require_tool(umdh, "UMDH") + selected = _artifacts(paths, upstream, artifacts) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + factory = NodeFactory(renderer, BUILD_ROOT, {}) + umdh_signature = {f"umdh.{key}": value for key, value in umdh_identity.items()} | {"umdh.path": str(umdh)} + + def action(name: str, pool: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], + *, identity: Mapping[str, str] | None = None, config: Mapping[str, str] | None = None) -> Node: + return python_action( + factory, name, "core.leak", arguments, dependencies, + pool=pool, identity=identity, config=config, + ) + + ordered = tuple(selected[name] for name in _BINARIES) + setup_dependencies = (selected["leak-probe"].producer,) + tuple( + dependency for item in ordered if item.module != "leak-probe" + for dependency in (item.producer, *required_audit_gates(upstream, item.producer, "x64", item.module)) + ) + setup = action( + "leak-setup-x64-release", "leak", ("setup", *(str(item.path) for item in ordered)), setup_dependencies, + config={"architecture": "x64", "configuration": "Release"}, + ) + setup_output = paths.cas(setup.uid, setup.name).output + nodes: list[Node] = [setup] + targets: list[str] = [] + + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"{mode}-{scenario}" + preflight = action(f"leak-preflight-{stem}", "leak", + ("preflight", str(setup_output), mode, scenario), (setup,)) + capture = action(f"leak-capture-{stem}", "leak-session", + ("capture", str(setup_output), str(umdh), mode, scenario, + str(warmup), str(iterations), str(windows)), + (preflight,), identity=umdh_signature, config={"run_nonce": run_nonce}) + capture_output = paths.cas(capture.uid, capture.name).output + snapshots = capture_output / "snapshots" + specs = [(f"window-{index}", snapshots / f"window-{index}.txt", snapshots / f"window-{index + 1}.txt") + for index in range(1, windows)] + specs.append(("overall", snapshots / "window-1.txt", snapshots / f"window-{windows}.txt")) + diffs = tuple( + action(f"leak-diff-{stem}-{label}", "leak-diff", + ("diff", str(umdh), str(setup_output), label, str(before), str(after)), + (capture,), identity=umdh_signature) + for label, before, after in specs + ) + judge = action(f"leak-judge-{stem}", "leak", + ( + "judge", mode, scenario, str(warmup), str(iterations), str(windows), + str(tolerance_bytes), + *(value for (label, _before, _after), item in zip(specs, diffs, strict=True) + for value in (label, str(paths.cas(item.uid, item.name).output / "diff.json"))), + ), diffs) + nodes.extend((preflight, capture, *diffs, judge)) + targets.append(judge.name) + + pools = extend_pools(upstream, {"leak": jobs, "leak-session": session_jobs, "leak-diff": diff_jobs}) + return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools) diff --git a/tools/build/graphs/native.py b/tools/build/graphs/native.py new file mode 100644 index 0000000..e48c3b4 --- /dev/null +++ b/tools/build/graphs/native.py @@ -0,0 +1,288 @@ +"""Fine-grained native project build and Catch2 execution graph.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from core.graph import Graph, Node, merge_graphs +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + dependency_discovery_slice, + dependency_inputs, + dependency_node_name, +) +from graphs.common import ( + BINARIES, + BUILD_ROOT, + COMMON_PROJECT_INPUTS, + PLATFORMS, + PROJECTS, + project_inputs, + project_path, + project_sources, + require_positive_integers, + tool_environment, +) + + +_CONFIGURATIONS = ("Debug", "Release") +_COMMON_INPUTS = COMMON_PROJECT_INPUTS +_project_inputs = project_inputs +_relative = project_path + + +def _validate_matrix( + jobs: int, architectures: tuple[str, ...], configurations: tuple[str, ...] +) -> None: + require_positive_integers((jobs,), "jobs must be a positive integer") + unsupported = set(architectures) - PLATFORMS.keys() + if unsupported: + raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}") + invalid = set(configurations) - set(_CONFIGURATIONS) + if invalid: + raise ValueError(f"unsupported configuration: {sorted(invalid)[0]}") + + +def native_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), + include_leak_probe: bool = False, +) -> Graph: + """Discover exact native build dependencies per configuration and TU.""" + + _validate_matrix(jobs, architectures, configurations) + graphs = tuple( + dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS + ( + ("leak-probe",) if include_leak_probe and config == "Release" else () + ), + configuration=config, + name_qualifier=config.lower(), + jobs=jobs, + architectures=architectures, + ) + for config in configurations + ) + return merge_graphs(*graphs) + + +def _build( + repository: Path, + toolchain: MsvcToolchain, + factory: NodeFactory, + discovery: Graph, + manifests: Mapping[str, bytes], + restore_output: Path, + project_name: str, + architecture: str, + configuration: str, + platform: str, +) -> Node: + project = repository / "build/projects" / f"{project_name}.vcxproj" + inputs = project_inputs(repository, project) + files = {path: (repository / path).read_bytes() for path in inputs} + dependencies = [] + for source in project_sources(repository, project): + name = dependency_node_name( + repository, architecture, project_name, source, configuration.lower() + ) + dependencies.append(discovery.node(name)) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs(repository, restore_output, source, manifest)) + return factory.make( + "native-build.ps1", + f"build-{project_name}-{architecture}-{configuration.lower()}", + "slot", + { + "pwsh": str(toolchain.pwsh), + "msbuild": str(toolchain.msbuild), + "project": str(project), + "target": "Build", + "configuration": configuration, + "platform": platform, + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + }, + files=files, + dependencies=tuple(dependencies), + config={ + "action": "build", + "architecture": architecture, + "configuration": configuration, + "project": project_name, + }, + ) + + +def _test_shard( + repository: Path, + toolchain: MsvcToolchain, + factory: NodeFactory, + paths: BuildPaths, + builds: tuple[Node, ...], + architecture: str, + configuration: str, + shard_count: int, + shard_index: int, + corpus: Path | None, + run_nonce: str, +) -> Node: + artifacts = [ + { + "name": BINARIES[project], + "source": str(paths.cas(node.uid, node.name).output / BINARIES[project]), + } + for project, node in zip(PROJECTS, builds, strict=True) + ] + prefix = "corpus" if corpus is not None else "test" + name = f"{prefix}-shard-{architecture}-{configuration.lower()}-{shard_index}" + config = { + "action": "test", + "architecture": architecture, + "configuration": configuration, + "shard_count": str(shard_count), + "shard_index": str(shard_index), + } + options = {} + if corpus is not None: + config |= {"corpus": str(corpus), "run_nonce": run_nonce} + options["environment"] = tool_environment( + toolchain, extra=(("OBSERVER_TEST_CORPUS", str(corpus)),) + ) + return factory.make( + "native-corpus-test.ps1" if corpus is not None else "native-test.ps1", + name, + "slot", + { + "pwsh": str(toolchain.pwsh), + "artifacts": artifacts, + "shard_count": shard_count, + "shard_index": shard_index, + }, + files={}, + dependencies=builds, + config=config, + **options, + ) + + +def native_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None = None, + manifests: Mapping[str, bytes] | None = None, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), + runnable_architectures: tuple[str, ...], + test_shards: int = 4, + include_leak_probe: bool = False, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Return independently cacheable project builds and safe Catch2 shards.""" + + _validate_matrix(jobs, architectures, configurations) + require_positive_integers((test_shards,), "test_shards must be a positive integer") + unsupported = set(runnable_architectures) - PLATFORMS.keys() + if unsupported: + raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}") + corpus_path = None + if corpus is not None: + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("corpus run nonce must be a non-empty string without NUL") + corpus_path = Path(corpus).resolve(strict=True) + if not corpus_path.is_dir(): + raise NotADirectoryError(corpus_path) + if discovery is None or manifests is None: + raise ValueError("native dependency discovery is required") + + root = repository.resolve(strict=True) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + factory = NodeFactory( + renderer, root, dict(toolchain.identity), tool_environment(toolchain) + ) + paths = BuildPaths(root) + nodes: list[Node] = list(discovery.nodes) + targets: list[str] = [] + runnable = set(runnable_architectures) + + for architecture in architectures: + restore = discovery.node(f"restore-vcpkg-{architecture}") + restore_output = paths.cas(restore.uid, restore.name).output + for configuration in configurations: + builds = tuple( + _build( + root, + toolchain, + factory, + discovery, + manifests, + restore_output, + project, + architecture, + configuration, + PLATFORMS[architecture], + ) + for project in PROJECTS + ) + nodes.extend(builds) + leak_probe = None + if include_leak_probe and architecture == "x64" and configuration == "Release": + leak_probe = _build( + root, + toolchain, + factory, + discovery, + manifests, + restore_output, + "leak-probe", + architecture, + configuration, + PLATFORMS[architecture], + ) + nodes.append(leak_probe) + + if architecture in runnable: + runs = [(None, "")] + if corpus_path is not None: + runs.append((corpus_path, run_nonce)) + for shard_corpus, nonce in runs: + shards = tuple( + _test_shard( + root, + toolchain, + factory, + paths, + builds, + architecture, + configuration, + test_shards, + index, + shard_corpus, + nonce, + ) + for index in range(test_shards) + ) + nodes.extend(shards) + targets.extend(node.name for node in shards) + else: + targets.extend(node.name for node in builds) + if leak_probe is not None: + targets.append(leak_probe.name) + + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) diff --git a/tools/build/graphs/package.py b/tools/build/graphs/package.py new file mode 100644 index 0000000..f409d54 --- /dev/null +++ b/tools/build/graphs/package.py @@ -0,0 +1,194 @@ +"""Fine-grained deterministic packaging over explicit release build artifacts.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.package import ARCHITECTURES, LICENSES, MODULES +from core.paths import BuildPaths +from core.render import TemplateRenderer +from graphs.common import ( + BUILD_ROOT, extend_pools, produced_path, python_action, required_audit_gates, require_positive_integers, +) + + +@dataclass(frozen=True, slots=True) +class PackageArtifact: + architecture: str + module: str + producer: Node + binary: Path + symbols: Path + + +@dataclass(frozen=True, slots=True) +class PackageSmokeArtifact: + architecture: str + producer: Node + executable: Path + + +def package_outputs(repository: Path, graph: Graph) -> tuple[Path, ...]: + """Return exact CAS ZIP paths from a composed package graph, without materializing copies.""" + + paths, by_name, result = BuildPaths(repository.resolve(strict=True)), {node.name: node for node in graph.nodes}, [] + for architecture in sorted(ARCHITECTURES): + symbols = by_name.get(f"package-symbols-{architecture}") + if symbols is None: + continue + for module in MODULES: + node = graph.node(f"package-archive-{architecture}-{module}") + result.append(paths.cas(node.uid, node.name).output / f"{module}-{architecture}-dll.zip") + result.append(paths.cas(symbols.uid, symbols.name).output / f"observer-modules-{architecture}-pdb.zip") + if not result: + raise ValueError("graph has no package outputs") + return tuple(result) + + +def package_graph( + repository: Path, + upstream: Graph, + artifacts: Iterable[PackageArtifact], + *, + smoke_tests: Iterable[PackageSmokeArtifact] = (), + jobs: int = 4, +) -> Graph: + """Compose module and symbol packages without artificial cross-architecture edges.""" + + require_positive_integers((jobs,), "package jobs must be a positive integer") + root = repository.resolve(strict=True) + paths = BuildPaths(root) + factory = NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), BUILD_ROOT, {}) + + def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], + files: dict[str, bytes] | None = None) -> Node: + return python_action(factory, name, "core.package", arguments, dependencies, + pool="package", files=files) + + selected = sorted(tuple(artifacts), key=lambda item: (item.architecture, item.module)) + if not selected: + raise ValueError("package graph requires at least one artifact") + + keys = [(item.architecture, item.module) for item in selected] + if len(keys) != len(set(keys)): + raise ValueError("duplicate package artifact") + if any(architecture not in ARCHITECTURES or module not in MODULES for architecture, module in keys): + raise ValueError("invalid package artifact identity") + if len(keys) != len(MODULES) * len({architecture for architecture, _module in keys}): + raise ValueError("each architecture requires the complete module set") + + nodes: list[Node] = [] + archives: dict[str, Node] = {} + validations: dict[str, Node] = {} + symbol_stages = {architecture: [] for architecture in sorted({item.architecture for item in selected})} + + def output(node: Node, name: str = "") -> Path: + return paths.cas(node.uid, node.name).output / name + + def produced(producer: Node, path: Path, name: str, message: str) -> Path: + current = produced_path(paths, upstream, producer, path, message) + if current != output(producer, name): + raise ValueError(message) + return current + + for artifact in selected: + architecture, module, producer = artifact.architecture, artifact.module, artifact.producer + suffix = f"{architecture}-{module}" + message = "package artifact must use its exact producer CAS" + binary = produced(producer, artifact.binary, f"{module}.so", message) + symbols = produced(producer, artifact.symbols, f"{module}.pdb", message) + gates = required_audit_gates(upstream, producer, architecture, module) + dependencies = (producer, *gates) + + repository_inputs = ( + f"src/modules/{module}/observer_user.ini", + "LICENSE.txt", + *(f"licenses/{name}" for name in LICENSES[module]), + ) + stage = action( + f"package-stage-{suffix}", + ("stage-module", architecture, module, str(binary), str(root)), + dependencies, + {name: (root / name).read_bytes() for name in repository_inputs}, + ) + symbol_stage = action( + f"package-symbol-stage-{suffix}", + ("stage-symbol", architecture, module, str(symbols)), + dependencies, + ) + archive = action( + f"package-archive-{suffix}", + ("archive-module", architecture, module, str(output(stage))), + (stage, *gates), + ) + archive_name = f"{module}-{architecture}-dll.zip" + validation = action( + f"package-validate-{suffix}", + ("validate-module", architecture, module, str(output(archive, archive_name)), str(output(stage))), + (archive, stage), + ) + nodes.extend((stage, symbol_stage, archive, validation)) + archives[archive_name] = archive + validations[archive_name] = validation + symbol_stages[architecture].append(symbol_stage) + + for architecture, stages in sorted(symbol_stages.items()): + current = action( + f"package-symbols-{architecture}", + ( + "archive-symbols", architecture, + *(str(output(stage)) for stage in stages), + ), + tuple(stages), + ) + nodes.append(current) + archive_name = f"observer-modules-{architecture}-pdb.zip" + validation = action( + f"package-symbols-validate-{architecture}", + ("validate-symbols", architecture, str(output(current, archive_name)), + *(str(output(stage)) for stage in stages)), + (current, *stages), + ) + nodes.append(validation) + archives[archive_name] = current + validations[archive_name] = validation + + smokes = sorted(tuple(smoke_tests), key=lambda item: item.architecture) + if len(smokes) != len({smoke.architecture for smoke in smokes}): + raise ValueError("duplicate package smoke architecture") + smoke_nodes = [] + for smoke in smokes: + message = "package smoke must use its exact test producer CAS" + if smoke.architecture not in symbol_stages: + raise ValueError(message) + test_executable = produced(smoke.producer, smoke.executable, "tests.exe", message) + for module in MODULES: + archive_name = f"{module}-{smoke.architecture}-dll.zip" + archive = archives[archive_name] + validation = validations[archive_name] + smoke_node = action( + f"package-smoke-{smoke.architecture}-{module}", + ( + "smoke", smoke.architecture, module, + str(output(archive, archive_name)), + str(test_executable), + ), + (validation, smoke.producer), + ) + nodes.append(smoke_node) + smoke_nodes.append(smoke_node) + + aggregate = action( + "package-manifest", + ( + "aggregate", + *(str(output(node, name)) for name, node in archives.items()), + ), + tuple(validations.values()) + tuple(smoke_nodes), + ) + nodes.append(aggregate) + return Graph(upstream.nodes + tuple(nodes), (aggregate.name,), extend_pools(upstream, {"package": jobs})) diff --git a/tools/build/graphs/python_coverage.py b/tools/build/graphs/python_coverage.py new file mode 100644 index 0000000..5c289c7 --- /dev/null +++ b/tools/build/graphs/python_coverage.py @@ -0,0 +1,55 @@ +"""Content-addressed Python line-and-branch coverage gate.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from core.graph import Graph +from core.node import NodeFactory +from core.render import TemplateRenderer +from graphs.common import BUILD_ROOT, python_action + + +def _inputs(repository: Path, build_root: Path) -> dict[str, bytes]: + paths = [ + build_root / name + for name in ("driver.py", "main.py", "pyproject.toml", "uv.lock") + ] + for directory in ("core", "graphs", "tests"): + paths.extend(sorted((build_root / directory).rglob("*.py"))) + return {path.relative_to(repository).as_posix(): path.read_bytes() for path in paths} + + +def _tool_digest(build_root: Path, executable: Path) -> str: + package = (build_root / ".venv/Lib/site-packages/coverage").resolve(strict=True) + if not package.is_dir(): + raise FileNotFoundError(f"project coverage package is not a directory: {package}") + paths = [executable] + [path for path in sorted(package.rglob("*")) + if path.is_file() and "__pycache__" not in path.parts] + digest = hashlib.sha256() + for path in paths: + digest.update(path.relative_to(build_root).as_posix().encode() + b"\0") + with path.open("rb") as stream: + digest.update(hashlib.file_digest(stream, "sha256").digest()) + return digest.hexdigest() + + +def python_coverage_graph(repository: Path) -> Graph: + """Build one demand gate using only the repository-local pinned coverage executable.""" + + root = repository.resolve(strict=True) + build_root = root / "tools/build" + coverage = (build_root / ".venv/Scripts/coverage.exe").resolve(strict=True) + if not coverage.is_file(): + raise FileNotFoundError(f"project coverage executable is not a file: {coverage}") + digest = _tool_digest(build_root, coverage) + factory = NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), BUILD_ROOT, {}) + gate = python_action( + factory, "python-coverage", "core.python_coverage", (str(coverage), str(build_root)), (), + pool="python-coverage", files=_inputs(root, build_root), + identity={"coverage.path": str(coverage), "coverage.sha256": digest}, + config={"action": "python-coverage", "coverage": "100-percent-line-and-branch"}, + environment=(("PYTHONDONTWRITEBYTECODE", "1"),), + ) + return Graph((gate,), (gate.name,), {"python-coverage": 1}) diff --git a/tools/build/graphs/sanitizer.py b/tools/build/graphs/sanitizer.py new file mode 100644 index 0000000..c8b1c4e --- /dev/null +++ b/tools/build/graphs/sanitizer.py @@ -0,0 +1,281 @@ +"""Fine-grained ASan/UBSan tests over explicit sanitizer build artifacts.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from core.toolchain import MsvcToolchain +from graphs.common import ( + BINARIES, + BUILD_ROOT, + extend_pools, + prefixed_identity, + produced_path, + python_action, + require_ancestor, + require_positive_integers, + require_tool, + tool_environment, +) +from graphs.instrumented import ( + InstrumentedArtifact, + InstrumentedVariant, + instrumented_build_slice, + instrumented_dependency_discovery_slice, +) + + +_SUPPORTED = {("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")} +_RUNTIME_NAMES = { + "x86": "clang_rt.asan_dynamic-i386.dll", + "x64": "clang_rt.asan_dynamic-x86_64.dll", +} +_OPTIONS = { + "asan": ("ASAN_OPTIONS", "halt_on_error=1:alloc_dealloc_mismatch=1"), + "ubsan": ("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1"), +} + + +@dataclass(frozen=True, slots=True) +class SanitizerArtifact: + sanitizer: str + architecture: str + name: str + producer: Node + path: Path + + +@dataclass(frozen=True, slots=True) +class AsanRuntime: + architecture: str + path: Path + identity: Mapping[str, str] + + +def _artifacts( + paths: BuildPaths, + upstream: Graph, + artifacts: Iterable[SanitizerArtifact | InstrumentedArtifact], +) -> dict[tuple[str, str], dict[str, SanitizerArtifact | InstrumentedArtifact]]: + selected: dict[tuple[str, str], dict[str, SanitizerArtifact | InstrumentedArtifact]] = {} + for artifact in artifacts: + sanitizer = artifact.sanitizer if isinstance(artifact, SanitizerArtifact) else artifact.kind + key = (sanitizer, artifact.architecture) + if key not in _SUPPORTED or artifact.name not in BINARIES: + raise ValueError(f"invalid sanitizer artifact identity: {(*key, artifact.name)}") + group = selected.setdefault(key, {}) + if artifact.name in group: + raise ValueError(f"duplicate sanitizer artifact: {(*key, artifact.name)}") + expected = f"build-{artifact.name}-{artifact.architecture}-{sanitizer}" + message = f"sanitizer artifact must use its exact producer CAS: {artifact.path}" + if artifact.producer.name != expected: + raise ValueError(message) + binary = produced_path(paths, upstream, artifact.producer, artifact.path, message) + producer_output = paths.cas(artifact.producer.uid, artifact.producer.name).output + if binary != producer_output / BINARIES[artifact.name]: + raise ValueError(f"sanitizer artifact must use its exact producer CAS: {binary}") + restore = ( + f"restore-vcpkg-asan-{artifact.architecture}" + if sanitizer == "asan" + else f"restore-vcpkg-{artifact.architecture}" + ) + require_ancestor( + upstream, artifact.producer, restore, + f"sanitizer build requires {restore} as a restore ancestor", + ) + group[artifact.name] = artifact + if not selected: + raise ValueError("sanitizer graph requires at least one complete artifact set") + for key, group in selected.items(): + if group.keys() != BINARIES.keys(): + raise ValueError(f"sanitizer {key} requires the complete artifact set") + return selected + + +def _runtimes( + selected: Mapping[tuple[str, str], object], runtimes: Iterable[AsanRuntime] +) -> dict[str, AsanRuntime]: + result = {} + for runtime in runtimes: + path = require_tool(runtime.path, "ASan runtime") + if ( + runtime.architecture not in _RUNTIME_NAMES + or path.name != _RUNTIME_NAMES[runtime.architecture] + or not runtime.identity + ): + raise ValueError(f"invalid ASan runtime identity: {runtime.architecture}") + if runtime.architecture in result: + raise ValueError(f"duplicate ASan runtime: {runtime.architecture}") + result[runtime.architecture] = AsanRuntime( + runtime.architecture, path, runtime.identity + ) + required = {architecture for sanitizer, architecture in selected if sanitizer == "asan"} + if result.keys() != required: + raise ValueError("an exact ASan runtime is required for each ASan artifact set") + return result + + +def sanitizer_artifact_graph( + repository: Path, + upstream: Graph, + artifacts: Iterable[SanitizerArtifact | InstrumentedArtifact], + *, + pwsh: Path, + pwsh_identity: Mapping[str, str], + asan_runtimes: Iterable[AsanRuntime] = (), + environment: tuple[tuple[str, str], ...] = (), + test_shards: int = 4, + jobs: int = 4, +) -> Graph: + """Compose test-only sanitizer shards and fail-closed log gates.""" + + require_positive_integers( + (test_shards, jobs), + "sanitizer counts and pool capacities must be positive integers", + ) + root = repository.resolve(strict=True) + paths = BuildPaths(root) + pwsh = require_tool(pwsh, "PowerShell") + selected = _artifacts(paths, upstream, artifacts) + runtimes = _runtimes(selected, asan_runtimes) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) + gate_factory = NodeFactory(renderer, BUILD_ROOT, {}) + nodes: list[Node] = [] + targets: list[str] = [] + + for (sanitizer, architecture), group in sorted(selected.items()): + builds = tuple(group[name].producer for name in BINARIES) + runtime = runtimes.get(architecture) if sanitizer == "asan" else None + identity = pwsh_id + runtime_copy = None + if runtime is not None: + identity = identity | prefixed_identity( + f"asan_runtime.{architecture}", runtime.path, runtime.identity + ) + runtime_copy = {"name": runtime.path.name, "source": str(runtime.path)} + factory = NodeFactory(renderer, root, identity, environment) + copies = tuple( + {"name": BINARIES[name], "source": str(group[name].path)} + for name in BINARIES + ) + options_name, options_value = _OPTIONS[sanitizer] + for index in range(test_shards): + shard = factory.make( + "sanitizer-test.ps1", + f"{sanitizer}-test-{architecture}-{index}", + "sanitizer-shard", + { + "pwsh": str(pwsh), "artifacts": copies, "runtime": runtime_copy, + "options_name": options_name, "options_value": options_value, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + config={ + "action": "sanitizer-test", "sanitizer": sanitizer, + "architecture": architecture, "shard_count": str(test_shards), + "shard_index": str(index), + }, + ) + log = paths.cas(shard.uid, shard.name).log + gate = python_action( + gate_factory, + f"{sanitizer}-gate-{architecture}-{index}", + "core.sanitizer", + ("gate", sanitizer, str(log)), + (shard,), + pool="sanitizer-gate", + ) + nodes.extend((shard, gate)) + targets.append(gate.name) + + return Graph( + upstream.nodes + tuple(nodes), tuple(targets), + extend_pools(upstream, {"sanitizer-shard": jobs, "sanitizer-gate": jobs}), + ) + + +def _instrumented_variants( + selections: tuple[tuple[str, str], ...], + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], +) -> tuple[InstrumentedVariant, ...]: + return tuple( + InstrumentedVariant( + sanitizer, + architecture, + llvm_runtime if sanitizer == "ubsan" else None, + llvm_runtime_identity if sanitizer == "ubsan" else {}, + ) + for sanitizer, architecture in selections + ) + + +def sanitizer_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + jobs: int = 2, +) -> Graph: + """Discover exact TU inputs for each requested sanitizer build.""" + + return instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=_instrumented_variants( + selections, llvm_runtime, llvm_runtime_identity + ), + jobs=jobs, + ) + + +def sanitizer_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], + asan_runtimes: Iterable[AsanRuntime] = (), + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + jobs: int = 4, + test_shards: int = 4, +) -> Graph: + """Build sanitizer artifacts and compose independent shard gates.""" + + variants = _instrumented_variants( + selections, llvm_runtime, llvm_runtime_identity + ) + upstream, produced = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + jobs=jobs, + ) + return sanitizer_artifact_graph( + repository, + upstream, + produced, + pwsh=toolchain.pwsh, + pwsh_identity=dict(toolchain.identity), + asan_runtimes=asan_runtimes, + environment=tool_environment(toolchain), + test_shards=test_shards, + jobs=jobs, + ) diff --git a/tools/build/graphs/source.py b/tools/build/graphs/source.py new file mode 100644 index 0000000..50da44e --- /dev/null +++ b/tools/build/graphs/source.py @@ -0,0 +1,196 @@ +"""Fine-grained repository source checks.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from core.graph import Graph, Node +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer +from core.source_tools import SourceTools +from graphs.common import python_action, restore_node, tool_environment + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_CPP_SUFFIXES = {".cpp", ".h", ".hpp"} +_POWERSHELL_SUFFIXES = {".ps1", ".psm1"} +_CPPCHECK_ARCHITECTURES = { + "x86": ("win32W", "_M_IX86=600"), + "x64": ("win64", "_M_X64=100"), + "arm64": ("win64", "_M_ARM64=1"), +} +_IGNORED_DIRECTORIES = {".git", ".artifacts", ".venv", "__pycache__", "out"} + + +def _relative(repository: Path, path: Path) -> str: + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def _slug(repository: Path, path: Path) -> str: + return _relative(repository, path).lower().replace("/", ".") + + +def _repository_files(repository: Path) -> tuple[Path, ...]: + files = [] + for directory, directories, names in repository.walk(): + directories[:] = sorted(set(directories) - _IGNORED_DIRECTORIES) + files.extend(directory / name for name in names if not name.startswith(".coverage")) + return tuple(sorted(files)) + + +def source_checks(repository: Path, tools: SourceTools, jobs: int = 4, + architectures: Iterable[str] = _CPPCHECK_ARCHITECTURES) -> Graph: + """Return independently cacheable format, analyzer, and contract checks.""" + + selected_architectures = tuple(architectures) + if (not selected_architectures or len(set(selected_architectures)) != len(selected_architectures) or + any(item not in _CPPCHECK_ARCHITECTURES for item in selected_architectures)): + raise ValueError("source architectures must be unique supported architecture names") + root = repository.resolve(strict=True) + renderer = TemplateRenderer(_BUILD_ROOT / "templates") + identities = dict(tools.identity) + restore_identity = {key: identities[key] for key in ("pwsh", "pwsh_version", "vcpkg_root")} + factory = NodeFactory(renderer, root, {}, tools.environment) + restore_factory = NodeFactory(renderer, root, restore_identity, tool_environment(tools)) + paths = BuildPaths(root) + + def tool_identity(*names: str) -> dict[str, str]: + return {key: identities[key] for name in names for key in (name, f"{name}_version")} + + cpp_sources = tuple(sorted( + path for path in (root / "src").rglob("*") + if path.is_file() and path.suffix in _CPP_SUFFIXES + )) + powershell_sources = (root / "build.ps1",) + tuple(sorted( + path for path in (root / "build").rglob("*") + if path.is_file() and path.suffix in _POWERSHELL_SUFFIXES + )) + contracts = tuple(sorted((root / "build/tests").glob("*.Tests.ps1"))) + + def leaf( + template: str, + name: str, + variables: dict[str, object], + inputs: tuple[Path, ...], + *, + dependencies: tuple[Node, ...] = (), + identity: dict[str, str], + config: dict[str, str], + ) -> Node: + return factory.make(template, name, "slot", variables, + files={_relative(root, path): path.read_bytes() for path in inputs}, + dependencies=dependencies, identity=identity, config=config, + ) + + format_nodes = tuple( + leaf( + "clang-format.ps1", + f"format-{_slug(root, source)}", + { + "pwsh": str(tools.pwsh), + "clang_format": str(tools.clang_format), + "source": str(source), + }, + (root / ".clang-format", source), + identity=tool_identity("pwsh", "clang_format"), + config={"action": "format", "source": _relative(root, source)}, + ) + for source in cpp_sources + ) + + restore_nodes = [] + cppcheck_nodes = [] + for architecture in selected_architectures: + platform, architecture_define = _CPPCHECK_ARCHITECTURES[architecture] + triplet = f"observer-{architecture}-windows-static" + restore = restore_node(root, tools, restore_factory, architecture) + restore_nodes.append(restore) + include_dir = paths.cas(restore.uid, restore.name).output / triplet / "include" + cppcheck_nodes.append( + leaf( + "cppcheck.ps1", + f"cppcheck-{architecture}", + { + "pwsh": str(tools.pwsh), + "cppcheck": str(tools.cppcheck), + "repository": str(root), + "source_dir": str(root / "src"), + "include_dir": str(include_dir), + "platform": platform, + "architecture_define": architecture_define, + "automation_id": f"cppcheck/{architecture}/", + }, + cpp_sources, + dependencies=(restore,), + identity=tool_identity("pwsh", "cppcheck"), + config={"action": "cppcheck", "architecture": architecture}, + ) + ) + cppcheck_nodes = tuple(cppcheck_nodes) + restore_nodes = tuple(restore_nodes) + + settings = root / "build/PSScriptAnalyzerSettings.psd1" + pssa_nodes = tuple( + leaf( + "psscriptanalyzer.ps1", + f"pssa-{_slug(root, source)}", + { + "pwsh": str(tools.pwsh), + "psscriptanalyzer": str(tools.psscriptanalyzer), + "repository": str(root), + "source": str(source), + "settings": str(settings), + "automation_id": f"psscriptanalyzer/{_relative(root, source)}/", + }, + (settings, source), + identity=tool_identity("pwsh", "psscriptanalyzer"), + config={"action": "psscriptanalyzer", "source": _relative(root, source)}, + ) + for source in powershell_sources + ) + + contract_inputs = _repository_files(root) + contract_nodes = tuple( + leaf( + "contract.ps1", + f"contract-{_slug(root, test)}", + { + "pwsh": str(tools.pwsh), + "test": str(test), + }, + contract_inputs, + identity=tool_identity("pwsh"), + config={"action": "contract", "test": _relative(root, test)}, + ) + for test in contracts + ) + + def output(current: Node) -> Path: + return paths.cas(current.uid, current.name).output + + def report(current: Node) -> Path: + name = "cppcheck.sarif" if current.name.startswith("cppcheck-") else "psscriptanalyzer.sarif" + return output(current) / name + + finding_nodes = cppcheck_nodes + pssa_nodes + merged = python_action( + factory, + "merge-source-findings", + "core.sarif", + ("merge", *(str(report(current)) for current in finding_nodes)), + finding_nodes, + pool="slot", + ) + direct = format_nodes + contract_nodes + gate = python_action( + factory, + "source-checks", + "core.sarif", + ("gate", str(output(merged) / "analysis.sarif")), + (merged,) + direct, + pool="slot", + ) + nodes = restore_nodes + format_nodes + cppcheck_nodes + pssa_nodes + contract_nodes + (merged, gate) + return Graph(nodes, (gate.name,), {"restore": 1, "slot": jobs}) diff --git a/tools/build/main.py b/tools/build/main.py new file mode 100644 index 0000000..bac1bd6 --- /dev/null +++ b/tools/build/main.py @@ -0,0 +1,204 @@ +"""PowerShell-compatible table-driven CLI for the local build DAG.""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Awaitable, Callable, Sequence +from datetime import UTC, datetime +import os +from pathlib import Path +import sys + +from core.doctor import main as doctor_main +from core.host import verify_route +from core.quality_tools import resolve_binskim, resolve_dumpbin, resolve_umdh +from core.source_tools import discover_source_tools +from core.toolchain import discover_msvc_toolchain +from driver import Driver as BuildDriver + + +_ARCHITECTURES = ("x86", "x64", "arm64") +_CONFIGURATIONS = ("Debug", "Release") +_FUZZ_TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah") + + +def _selection(choices: tuple[str, ...]) -> Callable[[str], tuple[str, ...]]: + def parse(value: str) -> tuple[str, ...]: + parts = tuple(item.strip() for item in value.split(",")) + if any(item.casefold() == "all" for item in parts): + return choices + lookup = {item.casefold(): item for item in choices} + try: + selected = tuple(lookup[item.casefold()] for item in parts if item) + except KeyError as error: + raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}") + if len(selected) != len(parts): + raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}") + return tuple(dict.fromkeys(selected)) + return parse + + +def _integer(minimum: int, maximum: int | None = None) -> Callable[[str], int]: + def parse(value: str) -> int: + try: + number = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("expected an integer") from error + if number < minimum or maximum is not None and number > maximum: + limit = f"{minimum}..{maximum}" if maximum is not None else f">= {minimum}" + raise argparse.ArgumentTypeError(f"expected {limit}") + return number + return parse + + +def _threshold(value: str) -> int: + if value != "100": + raise argparse.ArgumentTypeError("coverage threshold is fixed at 100") + return 100 + + +_OPTIONS: dict[str, tuple[tuple[str, ...], dict[str, object]]] = { + "arch": (("-Arch", "--arch"), {"type": _selection(_ARCHITECTURES), "default": ("x64",)}), + "config": (("-Config", "--config"), {"type": _selection(_CONFIGURATIONS), "default": ("Debug",)}), + "corpus": (("-Corpus", "--corpus"), {"type": Path}), + "restore": (("-RestoreFlavor", "--restore-flavor"), { + "type": _selection(("default", "asan")), "default": ("default",), + }), + "shards": (("-TestShards", "--test-shards"), {"type": _integer(1), "default": 4}), + "fuzz_seconds": (("-FuzzSeconds", "--fuzz-seconds"), {"type": _integer(1, 86400), "default": 60}), + "fuzz_target": (("-FuzzTarget", "--fuzz-target"), {"type": _selection(_FUZZ_TARGETS), "default": _FUZZ_TARGETS}), + "leak_warmup": (("-LeakWarmup", "--leak-warmup"), {"type": _integer(1, 1_000_000), "default": 8}), + "leak_iterations": (("-LeakIterations", "--leak-iterations"), {"type": _integer(1, 1_000_000), "default": 100}), + "leak_windows": (("-LeakWindows", "--leak-windows"), {"type": _integer(3, 10), "default": 3}), + "leak_tolerance": (("-LeakToleranceBytes", "--leak-tolerance-bytes"), { + "type": _integer(0, 1_073_741_824), "default": 0, + }), + "threshold": (("-CoverageThreshold", "--coverage-threshold"), {"type": _threshold, "default": 100}), + "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "stale-work"), "default": "all"}), +} + +_COMMAND_OPTIONS = { + "restore": ("arch", "restore"), "build": ("arch", "config"), + "test": ("arch", "config", "corpus", "shards"), "source-checks": ("arch",), + "compiler-analysis": ("arch",), + "test-coverage": ("arch", "corpus", "shards", "threshold"), + "test-asan": ("arch", "shards"), "test-ubsan": ("arch", "shards"), + "test-leaks": ("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"), + "fuzz": ("arch", "fuzz_seconds", "fuzz_target"), + "audit-binaries": ("arch",), "package": ("arch",), + "verify": ("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", + "leak_iterations", "leak_windows", "leak_tolerance", "threshold"), +} + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="observer-build") + commands = parser.add_subparsers(dest="command", required=True) + doctor = commands.add_parser("doctor") + doctor.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") + for name, options in _COMMAND_OPTIONS.items(): + command = commands.add_parser(name) + command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[2]) + command.add_argument("-Jobs", "--jobs", type=_integer(1)) + command.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") + for option in options: + flags, settings = _OPTIONS[option] + command.add_argument(*flags, dest=option, **settings) + clean = commands.add_parser("clean") + clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[2]) + clean.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") + flags, settings = _OPTIONS["clean_mode"] + clean.add_argument(*flags, dest="clean_mode", **settings) + return parser + + +def _run_id() -> str: + return datetime.now(UTC).strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}" + + +def run_clean(repository: Path, mode: str) -> int: + from core.clean import main as clean_main + return clean_main((str(repository), "--mode", mode)) + + +Invoker = Callable[[object, argparse.Namespace, object], Awaitable[tuple[Path, ...]]] + + +async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) -> tuple[Path, ...]: + outputs: tuple[Path, ...] = () + if "default" in args.restore: + outputs += await driver.restore(args.arch, flavors=("",)) + if "asan" in args.restore: + architectures = tuple(item for item in args.arch if item != "arm64") + if architectures: + outputs += await driver.restore(architectures, flavors=("asan",)) + return outputs + + +_INVOKE: dict[str, Invoker] = { + "restore": _restore, + "build": lambda driver, args, _toolchain: driver.build(args.arch, args.config), + "test": lambda driver, args, _toolchain: driver.test( + args.arch, args.config, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce + ), + "source-checks": lambda driver, args, toolchain: driver.source_checks( + args.arch, discover_source_tools(toolchain) + ), + "compiler-analysis": lambda driver, args, _toolchain: driver.compiler_analysis(args.arch), + "test-coverage": lambda driver, args, _toolchain: driver.test_coverage( + args.arch, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce + ), + "test-asan": lambda driver, args, _toolchain: driver.test_asan(args.arch, test_shards=args.shards), + "test-ubsan": lambda driver, args, _toolchain: driver.test_ubsan(args.arch, test_shards=args.shards), + "test-leaks": lambda driver, args, toolchain: driver.test_leaks( + run_nonce=args.run_nonce, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(), + umdh=resolve_umdh(), warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + ), + "fuzz": lambda driver, args, _toolchain: driver.fuzz( + run_nonce=args.run_nonce, seconds=args.fuzz_seconds, targets=args.fuzz_target + ), + "audit-binaries": lambda driver, args, toolchain: driver.audit( + args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim() + ), + "package": lambda driver, args, toolchain: driver.package( + args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim() + ), + "verify": lambda driver, args, _toolchain: driver.verify( + args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, + test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + ), +} + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or len(arguments) == 1 and arguments[0].casefold() == "help": + parser.print_help() + return 0 + args = parser.parse_args(arguments) + if args.command == "restore" and args.skip_restore: + parser.error("-SkipDependencyRestore is invalid for restore") + if args.command == "doctor": + return doctor_main(()) + if args.command == "clean": + return run_clean(args.repository, args.clean_mode) + if args.command in {"fuzz", "test-leaks"} and args.arch != ("x64",): + parser.error(f"{args.command} requires -Arch x64") + args.run_nonce = _run_id() + toolchain = discover_msvc_toolchain() + driver = BuildDriver(args.repository, args.run_nonce, toolchain, jobs=args.jobs) + outputs = asyncio.run(_INVOKE[args.command](driver, args, toolchain)) + if args.command == "verify": + for item in verify_route(args.arch).deferred: + print(f"[DEFERRED] {item.gate} {item.architecture}: {item.reason}") + for output in outputs: + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build/pyproject.toml b/tools/build/pyproject.toml new file mode 100644 index 0000000..658a49f --- /dev/null +++ b/tools/build/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "observer-build" +version = "0.1.0" +description = "Local content-addressed build graph for ObserverModules" +requires-python = "==3.14.6" +dependencies = [ + "coverage==7.15.2", + "filelock==3.32.2", + "jinja2==3.1.6", + "psutil==7.2.2", + "pywin32==312; sys_platform == 'win32'", +] + +[tool.uv] +package = false + +[tool.coverage.run] +branch = true +command_line = "-m unittest discover -s tests -p test_*.py" +source = ["."] + +[tool.coverage.report] +exclude_lines = [] +fail_under = 100 +include = ["core/*", "graphs/*", "driver.py", "main.py"] +show_missing = true +skip_covered = false diff --git a/tools/build/templates/analysis.ps1 b/tools/build/templates/analysis.ps1 new file mode 100644 index 0000000..d5e74b0 --- /dev/null +++ b/tools/build/templates/analysis.ps1 @@ -0,0 +1,6 @@ +{% extends "selected-compile.ps1" %} +{% block compile_args %} + '/p:ObserverRunCodeAnalysis=true' + '/p:RunCodeAnalysis=true' +{% block analyzer_args required %}{% endblock %} +{% endblock %} diff --git a/tools/build/templates/argv.json b/tools/build/templates/argv.json new file mode 100644 index 0000000..1e10f71 --- /dev/null +++ b/tools/build/templates/argv.json @@ -0,0 +1,3 @@ +{% extends "script.json" %} +{% block script_exec %}{{ argv | json }}{% endblock %} +{% block script_body %}{% endblock %} diff --git a/tools/build/templates/base.json b/tools/build/templates/base.json new file mode 100644 index 0000000..7c0fe5c --- /dev/null +++ b/tools/build/templates/base.json @@ -0,0 +1,6 @@ +{ + "name": {{ name | json }}, + "pool": {{ pool | json }}, + "inputs": {{ inputs | json }}, + "script": {% block script %}null{% endblock %} +} diff --git a/tools/build/templates/catch2-test.ps1 b/tools/build/templates/catch2-test.ps1 new file mode 100644 index 0000000..56fa743 --- /dev/null +++ b/tools/build/templates/catch2-test.ps1 @@ -0,0 +1,28 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +{% for artifact in artifacts %}Copy-Item -LiteralPath {{ artifact.source | ps_quote }} -Destination (Join-Path $outDir {{ artifact.name | ps_quote }}) +{% endfor %} +{% block catch2_setup %}{% endblock %}Push-Location $outDir +try { + Invoke-Checked (Join-Path $outDir 'tests.exe') @( +{% block test_filter %}{% endblock %} + '--reporter' + 'compact' + '--reporter' + 'JUnit::out=tests.xml' + '--durations' + 'yes' + '--order' + 'lex' + '--shard-count' + {{ shard_count | string | ps_quote }} + '--shard-index' + {{ shard_index | string | ps_quote }} + ) +} finally { + Pop-Location +} +{% block catch2_post %}if (-not (Test-Path -LiteralPath (Join-Path $outDir 'tests.xml') -PathType Leaf)) { + throw 'Catch2 did not produce tests.xml' +} +{% endblock %}{% endblock %} diff --git a/tools/build/templates/clang-command.ps1 b/tools/build/templates/clang-command.ps1 new file mode 100644 index 0000000..0a854f7 --- /dev/null +++ b/tools/build/templates/clang-command.ps1 @@ -0,0 +1,10 @@ +{% extends "selected-compile.ps1" %} +{% block compile_args %} + "/p:ObserverClangCommandPath=$outDir\compile-command.json" + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} +{% endblock %} +{% block post_msbuild %} +if (-not (Test-Path -LiteralPath (Join-Path $outDir 'compile-command.json') -PathType Leaf)) { + throw 'clang-cl did not produce compile-command.json' +} +{% endblock %} diff --git a/tools/build/templates/clang-format.ps1 b/tools/build/templates/clang-format.ps1 new file mode 100644 index 0000000..d5dd9cc --- /dev/null +++ b/tools/build/templates/clang-format.ps1 @@ -0,0 +1,8 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +Invoke-Checked {{ clang_format | ps_quote }} @( + '--dry-run' + '--Werror' + {{ source | ps_quote }} +) +{% endblock %} diff --git a/tools/build/templates/clang-tidy.ps1 b/tools/build/templates/clang-tidy.ps1 new file mode 100644 index 0000000..1594fd8 --- /dev/null +++ b/tools/build/templates/clang-tidy.ps1 @@ -0,0 +1,15 @@ +{% extends "analysis.ps1" %} +{% block int_dir %} + "/p:IntDir=$outDir\obj\" +{% endblock %} +{% block analyzer_args %} + '/p:EnableMicrosoftCodeAnalysis=false' + '/p:ObserverEnableClangTidy=true' + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} + {{ ('/p:ClangTidyLogFile=' ~ project_name ~ '.ClangTidy.log') | ps_quote }} +{% endblock %} +{% block post_msbuild %} +if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ ('obj\\' ~ project_name ~ '.ClangTidy.log') | ps_quote }}) -PathType Leaf)) { + throw {{ ('clang-tidy did not produce ' ~ project_name ~ '.ClangTidy.log') | ps_quote }} +} +{% endblock %} diff --git a/tools/build/templates/contract.ps1 b/tools/build/templates/contract.ps1 new file mode 100644 index 0000000..214e94e --- /dev/null +++ b/tools/build/templates/contract.ps1 @@ -0,0 +1,4 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +& {{ test | ps_quote }} +{% endblock %} diff --git a/tools/build/templates/coverage-corpus-test.ps1 b/tools/build/templates/coverage-corpus-test.ps1 new file mode 100644 index 0000000..b72d6ff --- /dev/null +++ b/tools/build/templates/coverage-corpus-test.ps1 @@ -0,0 +1,3 @@ +{% extends "coverage-test.ps1" %} +{% block test_filter %} '[compatibility]' +{% endblock %} diff --git a/tools/build/templates/coverage-merge.ps1 b/tools/build/templates/coverage-merge.ps1 new file mode 100644 index 0000000..5a98d6d --- /dev/null +++ b/tools/build/templates/coverage-merge.ps1 @@ -0,0 +1,11 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$profiles = @( +{% for directory in profile_directories %} Get-ChildItem -LiteralPath {{ directory | ps_quote }} -File -Filter '*.profraw' +{% endfor %}) | Sort-Object FullName | ForEach-Object FullName +if ($profiles.Count -eq 0) { + throw 'Coverage shards produced no LLVM raw profiles' +} +$arguments = @('merge', '-sparse') + $profiles + @('-o', (Join-Path $outDir 'coverage.profdata')) +Invoke-Checked {{ llvm_profdata | ps_quote }} $arguments +{% endblock %} diff --git a/tools/build/templates/coverage-report.ps1 b/tools/build/templates/coverage-report.ps1 new file mode 100644 index 0000000..d3d5c75 --- /dev/null +++ b/tools/build/templates/coverage-report.ps1 @@ -0,0 +1,24 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$arguments = @( + 'export' + {{ test_executable | ps_quote }} + '--instr-profile' + {{ profile | ps_quote }} + '--ignore-filename-regex' + {{ ignore_regex | ps_quote }} +{% for object in objects %} '--object' + {{ object | ps_quote }} +{% endfor %}{% if summary_only %} '--summary-only' +{% else %} '--format=lcov' +{% endif %}) +$content = & {{ llvm_cov | ps_quote }} @arguments +if ($LASTEXITCODE -ne 0) { + throw "llvm-cov failed with exit code $LASTEXITCODE" +} +$text = @($content | Where-Object { $_ -notmatch '^warning:' }) -join "`n" +if ([string]::IsNullOrWhiteSpace($text)) { + throw 'llvm-cov produced an empty report' +} +[IO.File]::WriteAllText((Join-Path $outDir {{ report_name | ps_quote }}), $text, [Text.UTF8Encoding]::new($false)) +{% endblock %} diff --git a/tools/build/templates/coverage-test.ps1 b/tools/build/templates/coverage-test.ps1 new file mode 100644 index 0000000..1fbfe77 --- /dev/null +++ b/tools/build/templates/coverage-test.ps1 @@ -0,0 +1,7 @@ +{% extends "catch2-test.ps1" %} +{% block catch2_setup %}$env:LLVM_PROFILE_FILE = Join-Path $outDir 'coverage-%m-%p.profraw' +{% endblock %} +{% block catch2_post %}if (-not (Get-ChildItem -LiteralPath $outDir -File -Filter '*.profraw')) { + throw 'Instrumented Catch2 shard produced no LLVM raw profiles' +} +{% endblock %} diff --git a/tools/build/templates/cppcheck.ps1 b/tools/build/templates/cppcheck.ps1 new file mode 100644 index 0000000..3b58cf9 --- /dev/null +++ b/tools/build/templates/cppcheck.ps1 @@ -0,0 +1,41 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +if (-not (Test-Path -LiteralPath {{ include_dir | ps_quote }} -PathType Container)) { + throw 'Cppcheck dependency headers were not restored' +} +Invoke-Checked {{ cppcheck | ps_quote }} @( + {{ source_dir | ps_quote }} + '--std=c++23' + {{ ('--platform=' ~ platform) | ps_quote }} + '-DWIN32=1' + '-D_WIN32=1' + '-DUNICODE=1' + '-D_UNICODE=1' + {{ ('-D' ~ architecture_define) | ps_quote }} + {{ ('-I' ~ source_dir) | ps_quote }} + {{ ('-I' ~ include_dir) | ps_quote }} + '--enable=warning,style,performance,portability' + '--check-level=exhaustive' + '--inconclusive' + '--inline-suppr' + '--suppress=missingIncludeSystem' + '--suppress=uninitMemberVarNoCtor:src/api.h' + '--suppress=*:out/cas/*-restore-vcpkg-*/out/*' + '--suppress=functionStatic' + {{ ('--relative-paths=' ~ repository) | ps_quote }} + '--output-format=sarif' + "--output-file=$outDir\cppcheck.sarif" + "--cppcheck-build-dir=$buildDir" +) +$reportPath = Join-Path $outDir 'cppcheck.sarif' +if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) { + throw 'Cppcheck did not produce cppcheck.sarif' +} +$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json +$index = 0 +foreach ($run in @($report.runs)) { + $run | Add-Member -NotePropertyName automationDetails -NotePropertyValue ([ordered]@{ id = {{ automation_id | ps_quote }} + "$index/" }) -Force + ++$index +} +$report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $reportPath -Encoding utf8 +{% endblock %} diff --git a/tools/build/templates/fuzz-build.ps1 b/tools/build/templates/fuzz-build.ps1 new file mode 100644 index 0000000..55b8699 --- /dev/null +++ b/tools/build/templates/fuzz-build.ps1 @@ -0,0 +1,11 @@ +{% extends "msbuild.ps1" %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} + {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }} +{% endblock %} +{% block post_msbuild %}if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ executable_name | ps_quote }}) -PathType Leaf)) { + throw 'MSBuild did not produce the fuzzer executable' +} +{% endblock %} diff --git a/tools/build/templates/fuzz-common.ps1 b/tools/build/templates/fuzz-common.ps1 new file mode 100644 index 0000000..cea037b --- /dev/null +++ b/tools/build/templates/fuzz-common.ps1 @@ -0,0 +1,36 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$fuzzer = {{ fuzzer | ps_quote }} +$seedDir = {{ seed_dir | ps_quote }} +$corpus = Join-Path $buildDir 'corpus' +$artifactDir = Join-Path $outDir 'artifacts' +if (-not (Test-Path -LiteralPath $fuzzer -PathType Leaf)) { throw 'Fuzzer executable was not found' } +if (-not (Test-Path -LiteralPath $seedDir -PathType Container)) { throw 'Fuzzer seed directory was not found' } +[void](New-Item -ItemType Directory -Path $corpus) +[void](New-Item -ItemType Directory -Path $artifactDir) +$seeds = @(Get-ChildItem -LiteralPath $seedDir -File | Sort-Object Name) +if ($seeds.Count -eq 0) { throw 'No checked-in fuzzer seeds were found' } +foreach ($seed in $seeds) { + if ($seed.Extension -eq '.hex') { + $hex = (Get-Content -LiteralPath $seed.FullName -Raw) -replace '\s', '' + if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { + throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" + } + [IO.File]::WriteAllBytes((Join-Path $corpus $seed.BaseName), [Convert]::FromHexString($hex)) + } else { + Copy-Item -LiteralPath $seed.FullName -Destination $corpus + } +} +{% if prior_corpus is defined and prior_corpus %}$priorCorpus = {{ prior_corpus | ps_quote }} +if (-not (Test-Path -LiteralPath $priorCorpus -PathType Container)) { throw 'Prior fuzzer corpus was not found' } +Get-ChildItem -LiteralPath $priorCorpus -File | Sort-Object Name | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $corpus -Force +} +{% endif %}Push-Location (Split-Path -Parent $fuzzer) +try { +{% block fuzz_invoke required %}{% endblock %} +} finally { + Pop-Location +} +{% block fuzz_post %}{% endblock %} +{% endblock %} diff --git a/tools/build/templates/fuzz-gate.ps1 b/tools/build/templates/fuzz-gate.ps1 new file mode 100644 index 0000000..1d3921a --- /dev/null +++ b/tools/build/templates/fuzz-gate.ps1 @@ -0,0 +1,10 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$status = {{ status | ps_quote }} +if (-not (Test-Path -LiteralPath $status -PathType Leaf)) { throw 'Fuzzer status was not found' } +$exitCode = 0 +if (-not [int]::TryParse((Get-Content -LiteralPath $status -Raw), [ref]$exitCode)) { + throw 'Fuzzer status is not an integer' +} +if ($exitCode -ne 0) { throw "Fuzzer exited with code $exitCode" } +{% endblock %} diff --git a/tools/build/templates/fuzz-replay.ps1 b/tools/build/templates/fuzz-replay.ps1 new file mode 100644 index 0000000..dbd3afe --- /dev/null +++ b/tools/build/templates/fuzz-replay.ps1 @@ -0,0 +1,10 @@ +{% extends "fuzz-common.ps1" %} +{% block fuzz_invoke %}$inputs = @(Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name) +Invoke-Checked $fuzzer (@($inputs.FullName) + @( + {{ ('-max_len=' ~ max_length) | ps_quote }} + '-rss_limit_mb=1024' + '-timeout=10' + '-print_final_stats=1' + "-artifact_prefix=$artifactDir\" +)) +{% endblock %} diff --git a/tools/build/templates/fuzz-run.ps1 b/tools/build/templates/fuzz-run.ps1 new file mode 100644 index 0000000..73d6b1c --- /dev/null +++ b/tools/build/templates/fuzz-run.ps1 @@ -0,0 +1,21 @@ +{% extends "fuzz-common.ps1" %} +{% block fuzz_invoke %}$PSNativeCommandUseErrorActionPreference = $false +& $fuzzer @( + $corpus + {{ ('-max_total_time=' ~ seconds) | ps_quote }} + {{ ('-max_len=' ~ max_length) | ps_quote }} + '-rss_limit_mb=1024' + '-timeout=10' + '-use_value_profile=1' + '-print_final_stats=1' + "-artifact_prefix=$artifactDir\" +) +$fuzzExitCode = $LASTEXITCODE +{% endblock %} +{% block fuzz_post %}$publishedCorpus = Join-Path $outDir 'corpus' +[void](New-Item -ItemType Directory -Path $publishedCorpus) +Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $publishedCorpus +} +[IO.File]::WriteAllText((Join-Path $outDir 'status.txt'), [string]$fuzzExitCode) +{% endblock %} diff --git a/tools/build/templates/instrumented-build.ps1 b/tools/build/templates/instrumented-build.ps1 new file mode 100644 index 0000000..7fe1480 --- /dev/null +++ b/tools/build/templates/instrumented-build.ps1 @@ -0,0 +1,8 @@ +{% extends "msbuild.ps1" %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + '/p:VcpkgManifestInstall=false' + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} +{% if llvm_dir %} {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} +{% endif %}{% if llvm_runtime %} {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }} +{% endif %}{% endblock %} diff --git a/tools/build/templates/msbuild.ps1 b/tools/build/templates/msbuild.ps1 new file mode 100644 index 0000000..22a6142 --- /dev/null +++ b/tools/build/templates/msbuild.ps1 @@ -0,0 +1,18 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +{% block pwsh_setup %}{% endblock %}Invoke-Checked {{ msbuild | ps_quote }} @( + {{ project | ps_quote }} + '/nologo' + '/m:1' + '/nr:false' + {{ ('/t:' ~ target) | ps_quote }} + '/p:BuildProjectReferences=false' + {{ ('/p:Configuration=' ~ configuration) | ps_quote }} + {{ ('/p:Platform=' ~ platform) | ps_quote }} + "/p:OutDir=$outDir\" +{% block int_dir %} "/p:IntDir=$buildDir\"{{ '\n' }}{% endblock %} +{% block msbuild_args %}{% for argument in msbuild_args %} + {{ argument | ps_quote }} +{% endfor %}{% endblock %} +){{ '\n' }}{% block post_msbuild %}{% endblock %} +{% endblock %} diff --git a/tools/build/templates/msvc-analyze.ps1 b/tools/build/templates/msvc-analyze.ps1 new file mode 100644 index 0000000..b44c2cc --- /dev/null +++ b/tools/build/templates/msvc-analyze.ps1 @@ -0,0 +1,12 @@ +{% extends "analysis.ps1" %} +{% block analyzer_args %} + '/p:EnableMicrosoftCodeAnalysis=true' + '/p:ObserverEnableClangTidy=false' + {{ ('/p:ObserverAnalysisReportName=' ~ project_name) | ps_quote }} + "/p:ObserverAnalysisReportPath=$outDir\{{ project_name }}.sarif" +{% endblock %} +{% block post_msbuild %} +if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ (project_name ~ '.sarif') | ps_quote }}) -PathType Leaf)) { + throw {{ ('MSVC analysis did not produce ' ~ project_name ~ '.sarif') | ps_quote }} +} +{% endblock %} diff --git a/tools/build/templates/native-build.ps1 b/tools/build/templates/native-build.ps1 new file mode 100644 index 0000000..af24b02 --- /dev/null +++ b/tools/build/templates/native-build.ps1 @@ -0,0 +1,10 @@ +{% extends "msbuild.ps1" %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + '/p:VcpkgManifestInstall=false' +{% if vcpkg_installed %} + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} +{% else %} + "/p:VcpkgInstalledDir=$buildDir\vcpkg\" +{% endif %} +{% endblock %} diff --git a/tools/build/templates/native-corpus-test.ps1 b/tools/build/templates/native-corpus-test.ps1 new file mode 100644 index 0000000..fbf2795 --- /dev/null +++ b/tools/build/templates/native-corpus-test.ps1 @@ -0,0 +1,3 @@ +{% extends "native-test.ps1" %} +{% block test_filter %} '[compatibility]' +{% endblock %} diff --git a/tools/build/templates/native-test.ps1 b/tools/build/templates/native-test.ps1 new file mode 100644 index 0000000..4b03140 --- /dev/null +++ b/tools/build/templates/native-test.ps1 @@ -0,0 +1 @@ +{% extends "catch2-test.ps1" %} diff --git a/tools/build/templates/psscriptanalyzer.ps1 b/tools/build/templates/psscriptanalyzer.ps1 new file mode 100644 index 0000000..206118e --- /dev/null +++ b/tools/build/templates/psscriptanalyzer.ps1 @@ -0,0 +1,27 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +Import-Module {{ psscriptanalyzer | ps_quote }} +$diagnostics = @(Invoke-ScriptAnalyzer -Path {{ source | ps_quote }} -Settings {{ settings | ps_quote }}) +$results = @($diagnostics | ForEach-Object { + $level = switch ($_.Severity.ToString()) { 'Error' { 'error' } 'Warning' { 'warning' } default { 'note' } } + [ordered]@{ + ruleId = $_.RuleName + level = $level + message = [ordered]@{ text = $_.Message } + locations = @([ordered]@{ physicalLocation = [ordered]@{ + artifactLocation = [ordered]@{ uri = [IO.Path]::GetRelativePath({{ repository | ps_quote }}, $_.ScriptPath).Replace('\', '/') } + region = [ordered]@{ startLine = [int]$_.Line; startColumn = [int]$_.Column } + }}) + } +}) +$sarif = [ordered]@{ + version = '2.1.0' + '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' + runs = @([ordered]@{ + automationDetails = [ordered]@{ id = {{ automation_id | ps_quote }} } + tool = [ordered]@{ driver = [ordered]@{ name = 'PSScriptAnalyzer' } } + results = $results + }) +} +$sarif | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath "$outDir\psscriptanalyzer.sarif" -Encoding utf8 +{% endblock %} diff --git a/tools/build/templates/pwsh.ps1 b/tools/build/templates/pwsh.ps1 new file mode 100644 index 0000000..c5426e8 --- /dev/null +++ b/tools/build/templates/pwsh.ps1 @@ -0,0 +1,30 @@ +{% extends "script.json" %} + +{% block script_exec %} +[{{ pwsh | json }},"-NoLogo","-NoProfile","-NonInteractive","-Command","$ErrorActionPreference = 'Stop'; & ([ScriptBlock]::Create([Console]::In.ReadToEnd()))"] +{% endblock %} + +{% block script_body %} +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$outDir = $env:OBSERVER_OUT_DIR +$buildDir = $env:OBSERVER_BUILD_DIR +if ([string]::IsNullOrWhiteSpace($outDir) -or [string]::IsNullOrWhiteSpace($buildDir)) { + throw 'OBSERVER_OUT_DIR and OBSERVER_BUILD_DIR are required' +} + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter()][string[]] $ArgumentList = @() + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath" + } +} + +{% block pwsh_body required %}{% endblock %} +{% endblock %} diff --git a/tools/build/templates/sanitizer-test.ps1 b/tools/build/templates/sanitizer-test.ps1 new file mode 100644 index 0000000..3af117c --- /dev/null +++ b/tools/build/templates/sanitizer-test.ps1 @@ -0,0 +1,8 @@ +{% extends "catch2-test.ps1" %} +{% block catch2_setup %}{% if runtime %}Copy-Item -LiteralPath {{ runtime.source | ps_quote }} -Destination (Join-Path $outDir {{ runtime.name | ps_quote }}) +{% endif %}$env:{{ options_name }} = {{ options_value | ps_quote }} +{% endblock %} +{% block catch2_post %}if (-not (Test-Path -LiteralPath (Join-Path $outDir 'tests.xml') -PathType Leaf)) { + throw 'Sanitizer Catch2 shard did not produce tests.xml' +} +{% endblock %} diff --git a/tools/build/templates/script.json b/tools/build/templates/script.json new file mode 100644 index 0000000..65d691b --- /dev/null +++ b/tools/build/templates/script.json @@ -0,0 +1,8 @@ +{% extends "base.json" %} + +{% block script %} +{ + "exec": {{ self.script_exec() | trim }}, + "data": {{ self.script_body() | trim | json }} +} +{% endblock %} diff --git a/tools/build/templates/selected-compile.ps1 b/tools/build/templates/selected-compile.ps1 new file mode 100644 index 0000000..5c774cd --- /dev/null +++ b/tools/build/templates/selected-compile.ps1 @@ -0,0 +1,16 @@ +{% extends "msbuild.ps1" %} +{% block pwsh_setup %} +{% if vcpkg_installed %}$vcpkgInstalledDir = {{ vcpkg_installed | ps_quote }} +{% else %}$vcpkgInstalledDir = Join-Path $buildDir 'vcpkg' +[void](New-Item -ItemType Directory -Force -Path $vcpkgInstalledDir) +{% endif %}{% endblock %} +{% block msbuild_args %} + '/p:ObserverCompileAnalysis=true' + '/p:ForceRebuild=true' + {{ ('/p:SelectedFiles=' ~ source) | ps_quote }} + '/p:SelectedFilesBuildPCH=false' + '/p:SelectedFilesBuildModules=false' + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + "/p:VcpkgInstalledDir=$vcpkgInstalledDir\" +{% block compile_args required %}{% endblock %} +{% endblock %} diff --git a/tools/build/templates/source-dependencies.ps1 b/tools/build/templates/source-dependencies.ps1 new file mode 100644 index 0000000..756601f --- /dev/null +++ b/tools/build/templates/source-dependencies.ps1 @@ -0,0 +1,9 @@ +{% extends "selected-compile.ps1" %} +{% block compile_args %} + "/p:ObserverSourceDependenciesPath=$outDir\dependencies.json" +{% endblock %} +{% block post_msbuild %} +if (-not (Test-Path -LiteralPath (Join-Path $outDir 'dependencies.json') -PathType Leaf)) { + throw 'MSVC did not produce dependencies.json' +} +{% endblock %} diff --git a/tools/build/templates/vcpkg.ps1 b/tools/build/templates/vcpkg.ps1 new file mode 100644 index 0000000..695888e --- /dev/null +++ b/tools/build/templates/vcpkg.ps1 @@ -0,0 +1,14 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +Invoke-Checked {{ vcpkg | ps_quote }} @( + 'install' + "--x-install-root=$outDir" + '--triplet' + {{ triplet | ps_quote }} + {{ ('--x-manifest-root=' ~ repository) | ps_quote }} + {{ ('--overlay-triplets=' ~ repository ~ '\\build\\vcpkg\\triplets') | ps_quote }} +) +if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ (triplet ~ '\\include') | ps_quote }}) -PathType Container)) { + throw 'vcpkg restore did not produce the include directory' +} +{% endblock %} diff --git a/tools/build/tests/test_analysis_graph.py b/tools/build/tests/test_analysis_graph.py new file mode 100644 index 0000000..77b8ab8 --- /dev/null +++ b/tools/build/tests/test_analysis_graph.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.paths import BuildPaths # noqa: E402 +from graphs.analysis import ( # noqa: E402 + analysis_discovery_slice, + analysis_slice, + load_dependency_manifests, +) + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class AnalysisSliceTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + +""" + files = { + "src/modules/renpy/pickle.cpp": '#include "pickle.h"\n', + "src/modules/renpy/pickle.h": "#pragma once\n", + "src/modules/rpgmaker/rpgmaker.cpp": "#include \n", + "build/projects/renpy.vcxproj": project.format( + source="src/modules/renpy/pickle.cpp" + ), + "build/projects/rpgmaker.vcxproj": project.format( + source="src/modules/rpgmaker/rpgmaker.cpp" + ), + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "build/ObserverNativeAnalysis.ruleset": "\n", + ".clang-tidy": "Checks: bugprone-*\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + "build/vcpkg/triplets/observer-x64-windows-static.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + "build/vcpkg/triplets/observer-x86-windows-static.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + } + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + tools.mkdir() + paths = { + "msbuild": tools / "MSBuild.exe", + "pwsh": tools / "pwsh.exe", + "vcpkg_root": tools / "vcpkg", + "llvm_dir": tools / "llvm", + } + paths["vcpkg_root"].mkdir() + (paths["vcpkg_root"] / "vcpkg.exe").touch() + paths["llvm_dir"].mkdir() + paths["msbuild"].touch() + paths["pwsh"].touch() + return FakeToolchain( + **paths, + environment=( + ("PATH", str(tools)), + ("VCPKG_ROOT", str(tools / "vcpkg-current")), + ), + identity={"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1.5"}, + ) + + @staticmethod + def manifest(source: Path, *includes: Path) -> bytes: + return json.dumps( + { + "Version": "1.2", + "Data": { + "Source": str(source.resolve()), + "ProvidedModule": "", + "ImportedModules": [], + "Includes": [str(path.resolve()) for path in includes], + }, + }, + separators=(",", ":"), + ).encode() + + def staged_graphs( + self, repository: Path, toolchain: FakeToolchain, *, jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + ): + discovery = analysis_discovery_slice( + repository, toolchain, jobs=jobs, architectures=architectures + ) + paths = BuildPaths(repository) + manifests = {} + for target in discovery.targets: + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + includes = ( + (repository / "src/modules/renpy/pickle.h",) + if target.endswith("modules.renpy.pickle") + else () + ) + if "-rpgmaker-" in target: + architecture = target.removeprefix("discover-dependencies-").split("-", 1)[0] + restore = discovery.node(f"restore-vcpkg-{architecture}") + package = paths.cas(restore.uid, restore.name).output / "include/zlib.h" + package.parent.mkdir(parents=True, exist_ok=True) + if not package.exists(): + package.write_text("#pragma once\n", encoding="utf-8") + includes = (package,) + manifests[target] = self.manifest(source, *includes) + graph = analysis_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + jobs=jobs, + architectures=architectures, + ) + return discovery, graph + + def test_two_analysis_backends_are_independent_demand_targets(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs(repository, self.toolchain(root)) + + self.assertEqual( + graph.targets, + ("analysis-x64",), + ) + self.assertEqual(graph.pools, {"misc": 2, "restore": 1, "slot": 2}) + self.assertEqual( + tuple(node.name for node in graph.nodes), + ( + "restore-vcpkg-x64", + "discover-dependencies-x64-renpy-modules.renpy.pickle", + "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "analyze-msvc-x64-renpy-modules.renpy.pickle", + "analyze-tidy-x64-renpy-modules.renpy.pickle", + "normalize-msvc-x64-renpy-modules.renpy.pickle", + "normalize-tidy-x64-renpy-modules.renpy.pickle", + "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "merge-analysis-x64", + "analysis-x64", + ), + ) + restore = graph.nodes[0] + self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg")) + raw = (graph.nodes[3], graph.nodes[4], graph.nodes[7], graph.nodes[8]) + for node in raw: + self.assertEqual(node.pool, "slot") + self.assertEqual(node.command.cwd, str(repository.resolve())) + self.assertEqual(dict(node.command.env)["PATH"], str(root / "fake tools")) + script = node.command.stdin.decode("utf-8") + self.assertIn("'/t:ClCompile'", script) + self.assertIn("'/p:Configuration=Debug'", script) + self.assertIn("'/p:Platform=x64'", script) + self.assertIn("'/p:SelectedFiles=", script) + self.assertIn("'/p:VcpkgRoot=", script) + self.assertEqual(raw[0].inputs, (discovery.targets[0],)) + self.assertEqual(raw[1].inputs, (discovery.targets[0],)) + self.assertEqual(raw[2].inputs, (discovery.targets[1],)) + self.assertEqual(raw[3].inputs, (discovery.targets[1],)) + msvc = raw[0].command.stdin.decode("utf-8") + tidy = raw[1].command.stdin.decode("utf-8") + self.assertIn('"/p:ObserverAnalysisReportPath=$outDir\\renpy.sarif"', msvc) + self.assertIn('"/p:IntDir=$buildDir\\"', msvc) + self.assertIn("MSVC analysis did not produce renpy.sarif", msvc) + self.assertIn(")\nif (-not (Test-Path", msvc) + self.assertIn('"/p:IntDir=$outDir\\obj\\"', tidy) + self.assertIn("'/p:LLVMInstallDir=", tidy) + self.assertIn("clang-tidy did not produce renpy.ClangTidy.log", tidy) + self.assertIn(")\nif (-not (Test-Path", tidy) + normalize_msvc, normalize_tidy = graph.nodes[5:7] + rpg_normalize_msvc, rpg_normalize_tidy, merge, gate = graph.nodes[9:] + self.assertEqual(normalize_msvc.inputs, (raw[0].name,)) + self.assertEqual(normalize_tidy.inputs, (raw[1].name,)) + self.assertEqual(rpg_normalize_msvc.inputs, (raw[2].name,)) + self.assertEqual(rpg_normalize_tidy.inputs, (raw[3].name,)) + self.assertEqual( + merge.inputs, + ( + normalize_msvc.name, + normalize_tidy.name, + rpg_normalize_msvc.name, + rpg_normalize_tidy.name, + ), + ) + self.assertEqual(gate.inputs, (merge.name,)) + self.assertEqual(normalize_msvc.command.argv[1:4], ("-m", "core.sarif", "normalize-msvc")) + self.assertEqual(normalize_tidy.command.argv[1:4], ("-m", "core.sarif", "convert-tidy")) + self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge")) + self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate")) + self.assertTrue( + all(node.pool == "misc" for node in graph.nodes[5:7] + graph.nodes[9:]) + ) + + def test_tidy_configuration_invalidates_only_tidy_node(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _before_discovery, before = self.staged_graphs(repository, toolchain) + (repository / ".clang-tidy").write_text( + "Checks: bugprone-*,performance-*\n", encoding="utf-8" + ) + _after_discovery, after = self.staged_graphs(repository, toolchain) + + unchanged = ( + "restore-vcpkg-x64", + "analyze-msvc-x64-renpy-modules.renpy.pickle", + "normalize-msvc-x64-renpy-modules.renpy.pickle", + "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + ) + changed = ( + "analyze-tidy-x64-renpy-modules.renpy.pickle", + "normalize-tidy-x64-renpy-modules.renpy.pickle", + "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "merge-analysis-x64", + "analysis-x64", + ) + for name in unchanged: + self.assertEqual(before.node(name).uid, after.node(name).uid) + for name in changed: + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + + def test_dynamic_include_forms_are_deferred_to_msvc_dependency_discovery(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + forms = ( + "#define HEADER \"pickle.h\"\n#include HEADER\n", + '#include_next "pickle.h"\n', + '#if __has_include("pickle.h")\n#include "pickle.h"\n#endif\n', + ) + for source in forms: + with self.subTest(source=source): + (repository / "src/modules/renpy/pickle.cpp").write_text( + source, encoding="utf-8" + ) + graph = analysis_discovery_slice(repository, toolchain) + + discovery = graph.node( + "discover-dependencies-x64-renpy-modules.renpy.pickle" + ) + script = discovery.command.stdin.decode("utf-8") + self.assertIn("/p:ObserverSourceDependenciesPath=", script) + self.assertIn("dependencies.json", script) + self.assertNotIn("ObserverRunCodeAnalysis", script) + + def test_architecture_selects_distinct_platform_triplet_and_target(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, self.toolchain(root), architectures=("x86",) + ) + + self.assertEqual(graph.targets, ("analysis-x86",)) + self.assertEqual(graph.nodes[0].name, "restore-vcpkg-x86") + self.assertIn("observer-x86-windows-static", graph.nodes[0].command.stdin.decode()) + raw_script = graph.nodes[3].command.stdin.decode() + self.assertIn("'/p:Platform=Win32'", raw_script) + self.assertIn("analyze-msvc-x86-renpy", graph.nodes[3].name) + + def test_phase_two_signs_only_compiler_reported_project_and_package_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + unrelated = repository / "src/unused.h" + unrelated.write_text("one\n", encoding="utf-8") + toolchain = self.toolchain(root) + _discovery, baseline = self.staged_graphs(repository, toolchain) + unrelated.write_text("two\n", encoding="utf-8") + _discovery, unrelated_changed = self.staged_graphs(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// changed\n", encoding="utf-8" + ) + _discovery, header_changed = self.staged_graphs(repository, toolchain) + package = next((repository / "out/cas").glob("*-restore-vcpkg-x64")) + (package / "out/include/zlib.h").write_text("// changed\n", encoding="utf-8") + _discovery, package_changed = self.staged_graphs(repository, toolchain) + + names = { + "renpy": "analyze-msvc-x64-renpy-modules.renpy.pickle", + "rpgmaker": "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + } + for name in names.values(): + self.assertEqual(baseline.node(name).uid, unrelated_changed.node(name).uid) + self.assertNotEqual( + unrelated_changed.node(names["renpy"]).uid, + header_changed.node(names["renpy"]).uid, + ) + self.assertEqual( + unrelated_changed.node(names["rpgmaker"]).uid, + header_changed.node(names["rpgmaker"]).uid, + ) + self.assertNotEqual( + header_changed.node(names["rpgmaker"]).uid, + package_changed.node(names["rpgmaker"]).uid, + ) + + def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = analysis_discovery_slice(repository, self.toolchain(root)) + paths = BuildPaths(repository) + expected = {} + for target in discovery.targets: + node = discovery.node(target) + cas = paths.cas(node.uid, node.name) + cas.output.mkdir(parents=True) + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + content = self.manifest(source) + (cas.output / "dependencies.json").write_bytes(content) + cas.touch.touch() + expected[target] = content + + loaded = load_dependency_manifests(repository, discovery) + + self.assertEqual(loaded, expected) + + def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = analysis_discovery_slice(repository, self.toolchain(root)) + + with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"): + load_dependency_manifests(repository, discovery) + + def test_prior_manifest_dependencies_seed_only_their_discovery_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + initial = analysis_discovery_slice(repository, toolchain) + renpy = initial.node(initial.targets[0]) + cas = BuildPaths(repository).cas(renpy.uid, renpy.name) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes( + self.manifest( + repository / "src/modules/renpy/pickle.cpp", + repository / "src/modules/renpy/pickle.h", + ) + ) + cas.touch.touch() + other = BuildPaths(repository).cas("1" * 32, renpy.name) + other.output.mkdir(parents=True) + (other.output / "dependencies.json").write_bytes( + (cas.output / "dependencies.json").read_bytes() + ) + other.touch.touch() + BuildPaths(repository).cas("2" * 32, renpy.name).entry.mkdir(parents=True) + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// topology may have changed\n", encoding="utf-8" + ) + after = analysis_discovery_slice(repository, toolchain) + + self.assertNotEqual(before.node(before.targets[0]).uid, after.node(after.targets[0]).uid) + self.assertEqual(before.node(before.targets[1]).uid, after.node(after.targets[1]).uid) + + def test_source_namespace_addition_invalidates_discovery_for_has_include(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/optional.h").write_text("#pragma once\n", encoding="utf-8") + after = analysis_discovery_slice(repository, toolchain) + + for name in before.targets: + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + + def test_malformed_cas_candidate_is_ignored_during_discovery_seed_lookup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + name = "discover-dependencies-x64-renpy-modules.renpy.pickle" + (repository / "out/cas" / f"{'z' * 32}-{name}").mkdir(parents=True) + (repository / "out/cas" / f"{'3' * 32}-discover-dependencies-x64-unused-unit").mkdir() + + graph = analysis_discovery_slice(repository, self.toolchain(root)) + + self.assertIn(name, graph.targets) + + def test_x64_only_leak_probe_is_not_scheduled_for_cross_architectures(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + (repository / "build/projects/leak-probe.vcxproj").write_bytes( + (repository / "build/projects/renpy.vcxproj").read_bytes() + ) + toolchain = self.toolchain(root) + _x86_discovery, x86 = self.staged_graphs( + repository, toolchain, architectures=("x86",) + ) + _x64_discovery, x64 = self.staged_graphs( + repository, toolchain, architectures=("x64",) + ) + + self.assertFalse(any("-leak-probe-" in node.name for node in x86.nodes)) + self.assertTrue(any("-leak-probe-" in node.name for node in x64.nodes)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_audit_graph.py b/tools/build/tests/test_audit_graph.py new file mode 100644 index 0000000..8b952bb --- /dev/null +++ b/tools/build/tests/test_audit_graph.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.binary_audit import AuditError, main as binary_audit_main # noqa: E402 +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.audit import BinaryArtifact, audit_graph # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command((str(Path("C:/tools/build.exe")),)), + inputs, + ) + + +class AuditGraphTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Graph, tuple[BinaryArtifact, ...], Path, Path]: + repository = root / "repo" + repository.mkdir() + restore = node("restore-release") + renpy = node("build-renpy-x64-release", inputs=(restore.name,)) + rpgmaker = node("build-rpgmaker-x86-release", inputs=(restore.name,)) + upstream = Graph( + (restore, renpy, rpgmaker), + (renpy.name, rpgmaker.name), + {"build": 2}, + ) + paths = BuildPaths(repository) + artifacts = ( + BinaryArtifact("x64", "renpy", renpy, paths.cas(renpy.uid, renpy.name).output / "renpy.so"), + BinaryArtifact( + "x86", "rpgmaker", rpgmaker, + paths.cas(rpgmaker.uid, rpgmaker.name).output / "rpgmaker.so", + ), + ) + tools = root / "tools" + tools.mkdir() + dumpbin, binskim = tools / "dumpbin.exe", tools / "BinSkim.exe" + dumpbin.touch() + binskim.touch() + return repository, upstream, artifacts, dumpbin, binskim + + def build_graph( + self, + root: Path, + *, + dumpbin_version: str = "14.44", + binskim_version: str = "4.4", + ) -> Graph: + repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + return audit_graph( + repository, + upstream, + artifacts, + dumpbin=dumpbin, + dumpbin_identity={"path": str(dumpbin), "version": dumpbin_version}, + binskim=binskim, + binskim_identity={"path": str(binskim), "version": binskim_version}, + jobs=3, + binskim_jobs=2, + ) + + def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build_graph(root) + + self.assertEqual(graph.pools, {"build": 2, "audit": 3, "binskim": 2, "dumpbin": 3}) + self.assertEqual( + graph.targets, + ( + "audit-pe-x64-renpy", + "audit-binskim-x64-renpy", + "audit-pe-x86-rpgmaker", + "audit-binskim-x86-rpgmaker", + ), + ) + self.assertEqual(15, len(graph.nodes)) + self.assertEqual(graph.node("build-renpy-x64-release").inputs, ("restore-release",)) + + for architecture, module, producer in ( + ("x64", "renpy", "build-renpy-x64-release"), + ("x86", "rpgmaker", "build-rpgmaker-x86-release"), + ): + dump_nodes = tuple( + graph.node(f"audit-dumpbin-{mode}-{architecture}-{module}") + for mode in ("headers", "dependents", "exports") + ) + pe_gate = graph.node(f"audit-pe-{architecture}-{module}") + binskim_run = graph.node(f"audit-binskim-run-{architecture}-{module}") + binskim_gate = graph.node(f"audit-binskim-{architecture}-{module}") + self.assertTrue(all(current.inputs == (producer,) for current in dump_nodes)) + self.assertEqual(pe_gate.inputs, tuple(current.name for current in dump_nodes)) + self.assertEqual(binskim_run.inputs, (producer,)) + self.assertEqual(binskim_gate.inputs, (binskim_run.name,)) + self.assertTrue(all(current.pool == "dumpbin" for current in dump_nodes)) + self.assertEqual((pe_gate.pool, binskim_run.pool, binskim_gate.pool), ("audit", "binskim", "audit")) + + def test_dumpbin_and_python_gates_receive_exact_binary_logs_and_report_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + graph = audit_graph( + repository, + upstream, + artifacts[:1], + dumpbin=dumpbin, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + ) + paths = BuildPaths(repository) + + dump_nodes = tuple( + graph.node(f"audit-dumpbin-{mode}-x64-renpy") + for mode in ("headers", "dependents", "exports") + ) + for mode, current in zip(("headers", "dependents", "exports"), dump_nodes, strict=True): + self.assertEqual(current.command.argv, (str(dumpbin), f"/{mode}", str(artifacts[0].path))) + pe_gate = graph.node("audit-pe-x64-renpy") + self.assertEqual(pe_gate.command.argv[1:5], ("-m", "core.binary_audit", "pe", "x64")) + self.assertEqual( + pe_gate.command.argv[5:], + tuple(str(paths.cas(current.uid, current.name).log) for current in dump_nodes), + ) + binskim_run = graph.node("audit-binskim-run-x64-renpy") + self.assertEqual( + binskim_run.command.argv[1:], + ("-m", "core.binary_audit", "run-binskim", str(binskim), str(artifacts[0].path)), + ) + binskim_gate = graph.node("audit-binskim-x64-renpy") + report = paths.cas(binskim_run.uid, binskim_run.name).output / "binskim.sarif" + self.assertEqual(binskim_gate.command.argv[1:], ("-m", "core.binary_audit", "binskim", str(report))) + + def test_tool_identity_invalidates_only_its_run_and_semantic_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + + def graph(dumpbin_version: str, binskim_version: str) -> Graph: + return audit_graph( + repository, + upstream, + artifacts, + dumpbin=dumpbin, + dumpbin_identity={"version": dumpbin_version}, + binskim=binskim, + binskim_identity={"version": binskim_version}, + ) + + before = graph("14.44", "4.4") + dump_changed = graph("14.45", "4.4") + binskim_changed = graph("14.44", "4.5") + + for current in before.nodes: + dump_partition = "dumpbin" in current.name or "audit-pe-" in current.name + binskim_partition = "binskim" in current.name + self.assertEqual( + dump_partition, + current.uid != dump_changed.node(current.name).uid, + current.name, + ) + self.assertEqual( + binskim_partition, + current.uid != binskim_changed.node(current.name).uid, + current.name, + ) + + def test_artifact_must_match_its_exact_upstream_producer_cas(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + escaped = BinaryArtifact("x64", "renpy", artifacts[0].producer, root / "outside.so") + with self.assertRaisesRegex(ValueError, "producer CAS"): + audit_graph( + repository, + upstream, + (escaped,), + dumpbin=dumpbin, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + ) + + def test_graph_rejects_ambiguous_tools_pools_artifacts_and_capacities(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + + def invoke( + selected: tuple[BinaryArtifact, ...] = artifacts, + *, + source: Graph = upstream, + dumpbin_path: Path = dumpbin, + jobs: object = 2, + binskim_jobs: object = 1, + ) -> Graph: + return audit_graph( + repository, + source, + selected, + dumpbin=dumpbin_path, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + jobs=jobs, # type: ignore[arg-type] + binskim_jobs=binskim_jobs, # type: ignore[arg-type] + ) + + for jobs, binskim_jobs in ((True, 1), ("two", 1), (2, 0)): + with self.subTest(capacities=(jobs, binskim_jobs)), self.assertRaisesRegex( + ValueError, "capacities" + ): + invoke(jobs=jobs, binskim_jobs=binskim_jobs) + + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(dumpbin_path=dumpbin.parent) + with self.assertRaisesRegex(ValueError, "at least one"): + invoke(()) + with self.assertRaisesRegex(ValueError, "duplicate"): + invoke((artifacts[0], artifacts[0])) + for invalid in ( + BinaryArtifact("riscv64", "renpy", artifacts[0].producer, artifacts[0].path), + BinaryArtifact("x64", "Bad Module", artifacts[0].producer, artifacts[0].path), + ): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "identity"): + invoke((invalid,)) + + impostor = node(artifacts[0].producer.name) + mismatched = BinaryArtifact("x64", "renpy", impostor, artifacts[0].path) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((mismatched,)) + expected_output = BuildPaths(repository).cas( + artifacts[0].producer.uid, artifacts[0].producer.name + ).output + for wrong_path in (expected_output, BuildPaths(repository).cas_root / "other.so"): + with self.subTest(wrong_path=wrong_path), self.assertRaisesRegex( + ValueError, "producer CAS" + ): + invoke((BinaryArtifact("x64", "renpy", artifacts[0].producer, wrong_path),)) + + matching_pools = Graph( + upstream.nodes, + upstream.targets, + {"build": 2, "audit": 2, "binskim": 1, "dumpbin": 2}, + ) + self.assertEqual(invoke(source=matching_pools).pools["audit"], 2) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 2, "audit": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflicting) + + def test_binary_audit_cli_dispatches_pe_binskim_and_literal_binskim_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + headers, dependents, exports = root / "headers.txt", root / "dependents.txt", root / "exports.txt" + headers.write_text(" 8664 machine (x64)\n", encoding="utf-8") + dependents.write_text(" KERNEL32.dll\n", encoding="utf-8") + exports.write_text( + " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n", encoding="utf-8" + ) + self.assertEqual( + 0, + binary_audit_main(("pe", "x64", str(headers), str(dependents), str(exports))), + ) + report = root / "approved.sarif" + report.write_text(json.dumps({"runs": []}), encoding="utf-8") + self.assertEqual(0, binary_audit_main(("binskim", str(report)))) + + output = root / "out" + output.mkdir() + tool, binary = root / "BinSkim.exe", root / "renpy.so" + tool.touch() + binary.touch() + + def complete(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[argv.index("--output") + 1]).write_text('{"runs":[]}', encoding="utf-8") + return subprocess.CompletedProcess(argv, 0) + + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False): + with mock.patch("core.binary_audit.subprocess.run", side_effect=complete) as invoked: + self.assertEqual(0, binary_audit_main(("run-binskim", str(tool), str(binary)))) + argv = invoked.call_args.args[0] + self.assertEqual(argv[:3], [str(tool), "analyze", str(binary)]) + self.assertIn("--disable-telemetry", argv) + self.assertEqual(output / "binskim.sarif", Path(argv[argv.index("--output") + 1])) + + with mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(AuditError, "OBSERVER_OUT_DIR"): + binary_audit_main(("run-binskim", str(tool), str(binary))) + empty_output = root / "empty-output" + empty_output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(empty_output)}, clear=False): + with mock.patch("core.binary_audit.subprocess.run"): + with self.assertRaisesRegex(AuditError, "did not produce"): + binary_audit_main(("run-binskim", str(tool), str(binary))) + + with ( + mock.patch.object(sys, "argv", ["binary_audit.py", "binskim", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.binary_audit"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.binary_audit", run_name="__main__") + self.assertEqual(0, raised.exception.code) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_binary_audit.py b/tools/build/tests/test_binary_audit.py new file mode 100644 index 0000000..bb48116 --- /dev/null +++ b/tools/build/tests/test_binary_audit.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.binary_audit import AuditError, require_clean_binskim, require_release_pe # noqa: E402 + + +class BinaryAuditTests(unittest.TestCase): + def test_release_pe_requires_machine_static_dependencies_and_exact_exports(self) -> None: + require_release_pe( + "x64", + " 8664 machine (x64)\n", + " KERNEL32.dll\n api-ms-win-core-file-l1-1-0.dll\n", + " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n", + ) + + failures = ( + ("x86", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "machine"), + ("x64", " 8664 machine (x64)\n", " VCRUNTIME140.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "dependencies"), + ("x64", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 Surprise\n", "exports"), + ) + for architecture, headers, dependents, exports, message in failures: + with self.subTest(message=message), self.assertRaisesRegex(AuditError, message): + require_release_pe(architecture, headers, dependents, exports) + + def test_binskim_allows_only_documented_warning(self) -> None: + approved = { + "runs": [{ + "tool": {"driver": {"rules": [ + {"id": "BA2027", "defaultConfiguration": {"level": "warning"}} + ]}}, + "results": [{"ruleId": "BA2027"}], + }] + } + require_clean_binskim(approved) + + for result in ( + {"ruleId": "BA2001", "level": "warning"}, + {"ruleId": "BA2027", "level": "error"}, + ): + document = { + "runs": [{ + "tool": {"driver": {"rules": []}}, + "results": [result], + }] + } + with self.subTest(result=result), self.assertRaisesRegex(AuditError, result["ruleId"]): + require_clean_binskim(document) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_clang_dependencies.py b/tools/build/tests/test_clang_dependencies.py new file mode 100644 index 0000000..3536f18 --- /dev/null +++ b/tools/build/tests/test_clang_dependencies.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.clang_dependencies import ( # noqa: E402 + ClangDependencyError, + dependency_manifest, + load_compile_command, + main, + scan_dependencies, +) + + +class ClangDependencyTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Path, Path, Path]: + source = root / "source file.cpp" + compiler = root / "clang-cl.exe" + scanner = root / "clang-scan-deps.exe" + command = root / "compile-command.json" + for path in (source, compiler, scanner): + path.touch() + command.write_text( + json.dumps( + { + "directory": str(root), + "file": str(source), + "output": "source.obj", + "arguments": [ + str(compiler), + "-xc++", + str(source), + "-o", + "source.obj", + "/clang:-MJcapture.json", + ], + } + ) + + ",\n", + encoding="utf-8", + ) + return source, compiler, scanner, command + + @staticmethod + def scan_document(source: Path, *includes: Path) -> dict[str, object]: + return { + "modules": [], + "translation-units": [ + { + "commands": [ + { + "input-file": str(source), + "file-deps": [str(source), *(str(path) for path in includes)], + } + ] + } + ], + } + + def test_load_command_validates_exact_compiler_source_and_removes_only_capture_flag(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, _scanner, command = self.fixture(root) + other_source = root / "other.cpp" + other_compiler = root / "other-clang.exe" + other_source.touch() + other_compiler.touch() + + loaded = load_compile_command(command, source, compiler) + + self.assertEqual(loaded["directory"], str(root.resolve())) + self.assertEqual(loaded["file"], str(source.resolve())) + self.assertEqual(loaded["arguments"][0], str(compiler.resolve())) + self.assertNotIn("/clang:-MJcapture.json", loaded["arguments"]) + self.assertIn("source.obj", loaded["arguments"]) + + for field, value, message in ( + ("directory", str(root / "missing"), "directory"), + ("directory", str(source), "directory"), + ("file", str(root / "missing.cpp"), "source"), + ("file", str(other_source), "source"), + ("arguments", [str(root / "missing-clang.exe")], "compiler"), + ("arguments", [str(other_compiler)], "compiler"), + ("arguments", "not-an-array", "arguments"), + ("arguments", [], "arguments"), + ("arguments", [str(compiler), 7], "arguments"), + ): + document = json.loads(command.read_text(encoding="utf-8").rstrip("\n,")) + document[field] = value + command.write_text(json.dumps(document) + ",\n", encoding="utf-8") + with self.subTest(field=field), self.assertRaisesRegex( + ClangDependencyError, message + ): + load_compile_command(command, source, compiler) + self.fixture(root) + + command.write_text("not json,\n", encoding="utf-8") + with self.assertRaisesRegex(ClangDependencyError, "JSON"): + load_compile_command(command, source, compiler) + command.write_text("[]", encoding="utf-8") + with self.assertRaisesRegex(ClangDependencyError, "JSON object"): + load_compile_command(command, source, compiler) + self.fixture(root) + command.write_text( + command.read_text(encoding="utf-8").rstrip("\n,"), encoding="utf-8" + ) + self.assertEqual(load_compile_command(command, source, compiler)["file"], str(source)) + + def test_scan_output_becomes_deterministic_absolute_deduplicated_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, first, second = root / "source.cpp", root / "first.h", root / "second.h" + for path in (source, first, second): + path.touch() + + manifest = dependency_manifest( + source, + self.scan_document(source, second, first, second), + ) + + self.assertEqual( + manifest, + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str(first.resolve()), str(second.resolve())], + } + }, + ) + + def test_scan_output_rejects_missing_source_and_invalid_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source.cpp" + source.touch() + missing = root / "missing.h" + other = root / "other.cpp" + other.touch() + directory = root / "directory" + directory.mkdir() + invalid_documents = ( + "bad", + {}, + {"translation-units": "bad"}, + {"translation-units": []}, + {"translation-units": [{}]}, + {"translation-units": [{"commands": []}]}, + {"translation-units": [{"commands": [{}]}]}, + {"translation-units": [{"commands": [{"file-deps": "bad"}]}]}, + {"translation-units": [{"commands": [{"file-deps": [str(source), 7]}]}]}, + {"translation-units": [{"commands": [{"file-deps": ["relative.h"]}]}]}, + self.scan_document(source, directory), + self.scan_document(source, missing), + self.scan_document(other), + ) + for document in invalid_documents: + with self.subTest(document=document), self.assertRaisesRegex( + ClangDependencyError, "scan output" + ): + dependency_manifest(source, document) + with self.assertRaisesRegex(ClangDependencyError, "source"): + dependency_manifest(root / "missing.cpp", self.scan_document(source)) + with self.assertRaisesRegex(ClangDependencyError, "source"): + dependency_manifest(root, self.scan_document(source)) + + def test_scan_runs_exact_tool_with_captured_compilation_database(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + header = root / "header.h" + header.touch() + build = root / "build" + build.mkdir() + captured: dict[str, object] = {} + + def run(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]: + database = Path(next(item.split("=", 1)[1] for item in argv if item.startswith("-compilation-database="))) + captured["argv"] = argv + captured["options"] = options + captured["database"] = json.loads(database.read_text(encoding="utf-8")) + return subprocess.CompletedProcess( + argv, 0, json.dumps(self.scan_document(source, header)), "" + ) + + with mock.patch("core.clang_dependencies.subprocess.run", side_effect=run): + manifest = scan_dependencies(source, command, scanner, compiler, build) + + self.assertEqual(manifest["Data"]["Includes"], [str(header.resolve())]) + self.assertEqual(captured["argv"][0], str(scanner.resolve())) + self.assertIn("-format=experimental-full", captured["argv"]) + self.assertEqual(captured["options"]["cwd"], str(root.resolve())) + self.assertEqual(len(captured["database"]), 1) + self.assertTrue(Path(captured["argv"][2].split("=", 1)[1]).is_relative_to(build)) + + def test_scan_failure_and_invalid_json_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + build = root / "build" + build.mkdir() + cases = ( + (subprocess.CompletedProcess([], 2, "", "bad flags"), "failed"), + (subprocess.CompletedProcess([], 2, "", ""), "no diagnostic"), + (subprocess.CompletedProcess([], 0, "not json", ""), "JSON"), + ) + for result, message in cases: + with ( + self.subTest(message=message), + mock.patch("core.clang_dependencies.subprocess.run", return_value=result), + self.assertRaisesRegex(ClangDependencyError, message), + ): + scan_dependencies(source, command, scanner, compiler, build) + with ( + mock.patch("core.clang_dependencies.subprocess.run", side_effect=OSError("boom")), + self.assertRaisesRegex(ClangDependencyError, "launch"), + ): + scan_dependencies(source, command, scanner, compiler, build) + for invalid_build in (root / "missing", source): + with self.assertRaisesRegex(ClangDependencyError, "build directory"): + scan_dependencies( + source, command, scanner, compiler, invalid_build + ) + + def test_cli_writes_canonical_json_only_below_observer_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + output = root / "out" + output.mkdir() + build = root / "build" + build.mkdir() + expected = {"Data": {"Source": str(source.resolve()), "Includes": []}} + arguments = ("scan", str(source), str(command), str(scanner), str(compiler)) + with ( + mock.patch("core.clang_dependencies.scan_dependencies", return_value=expected), + mock.patch.dict( + os.environ, + {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)}, + ), + ): + self.assertEqual(main(arguments), 0) + self.assertEqual( + (output / "dependencies.json").read_text(encoding="utf-8"), + json.dumps(expected, sort_keys=True, separators=(",", ":")) + "\n", + ) + + for environment, message in ( + ({}, "OBSERVER_OUT_DIR"), + ({"OBSERVER_OUT_DIR": str(source)}, "OBSERVER_OUT_DIR"), + ({"OBSERVER_OUT_DIR": str(output)}, "OBSERVER_BUILD_DIR"), + ( + { + "OBSERVER_OUT_DIR": str(output), + "OBSERVER_BUILD_DIR": str(source), + }, + "OBSERVER_BUILD_DIR", + ), + ( + { + "OBSERVER_OUT_DIR": str(output), + "OBSERVER_BUILD_DIR": str(root / "missing"), + }, + "OBSERVER_BUILD_DIR", + ), + ): + with ( + self.subTest(environment=environment), + mock.patch.dict(os.environ, environment, clear=True), + self.assertRaisesRegex(ClangDependencyError, message), + ): + main(arguments) + with ( + mock.patch.object(sys, "argv", ["clang_dependencies.py", *arguments]), + mock.patch.dict( + os.environ, + {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)}, + ), + mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + [], 0, json.dumps(self.scan_document(source)), "" + ), + ), + self.assertWarnsRegex(RuntimeWarning, "core.clang_dependencies"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.clang_dependencies", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_clean.py b/tools/build/tests/test_clean.py new file mode 100644 index 0000000..4b0a80d --- /dev/null +++ b/tools/build/tests/test_clean.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +import os +from pathlib import Path +import runpy +import shutil +import stat +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +from filelock import FileLock, Timeout + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.clean import CleanError, clean, main # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 + + +UID = "0123456789abcdef0123456789abcdef" + + +class CleanTests(unittest.TestCase): + def repository(self, root: Path) -> tuple[Path, BuildPaths]: + repository = root / "repo" + repository.mkdir(parents=True) + return repository, BuildPaths(repository) + + def test_all_removes_only_exact_generated_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + (paths.cas_root / "entry").mkdir() + (paths.cas_root / "entry/data").write_text("generated", encoding="utf-8") + run = paths.run_work("old-run") + run.mkdir() + inactive_lock = paths.lock(UID) + with FileLock(inactive_lock, timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + keep = repository / "keep.txt" + keep.write_text("user", encoding="utf-8") + + self.assertEqual(clean(repository), (paths.cas_root, run, inactive_lock)) + + self.assertFalse(paths.cas_root.exists()) + self.assertFalse(run.exists()) + self.assertEqual( + tuple(paths.locks_root.iterdir()), (paths.coordination_lock(),) + ) + self.assertEqual(keep.read_text(encoding="utf-8"), "user") + + paths.prepare() + self.assertTrue(paths.cas_root.is_dir()) + self.assertTrue(paths.work_root.is_dir()) + + def test_stale_work_removes_only_inactive_exact_run_directories(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + cached = paths.cas_root / "entry/out" + cached.mkdir(parents=True) + (cached / "module.so").touch() + runs = tuple(paths.run_work(name) for name in ("run-b", "run-a")) + for run in runs: + run.mkdir() + (run / "scratch.obj").touch() + lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lock: + pass + + removed = clean(repository, "stale-work") + + self.assertEqual(removed, tuple(sorted(runs))) + self.assertTrue((cached / "module.so").is_file()) + self.assertTrue(paths.locks_root.is_dir()) + self.assertTrue(paths.lock(UID).is_file()) + self.assertTrue(all(not run.exists() for run in runs)) + + def test_active_native_lock_refuses_every_mode_without_deleting(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + run = paths.run_work("active-run") + run.mkdir() + marker = run / "partial.obj" + marker.touch() + lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lock: + for mode in ("all", "stale-work"): + with self.subTest(mode=mode), self.assertRaisesRegex(CleanError, "active build"): + clean(repository, mode) + self.assertTrue(marker.is_file()) + + def test_active_run_lease_refuses_and_coordination_is_held_through_removal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + run = paths.run_work("leased-run") + run.mkdir() + lease = FileLock(paths.lease("leased-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lease, self.assertRaisesRegex(CleanError, "active build"): + clean(repository, "stale-work") + self.assertTrue(run.is_dir()) + + original_remove = shutil.rmtree + + def remove(target: Path) -> None: + with self.assertRaises(Timeout), FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + original_remove(target) + + with mock.patch("core.clean.shutil.rmtree", side_effect=remove): + self.assertEqual(clean(repository, "stale-work"), (run,)) + + def test_unsafe_layout_types_and_reparse_points_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file_repository, file_paths = self.repository(root / "file") + file_paths.output_root.touch() + with self.assertRaisesRegex(CleanError, "directory"): + clean(file_repository) + + unexpected_repository, unexpected = self.repository(root / "unexpected") + unexpected.output_root.mkdir() + (unexpected.output_root / "personal.txt").touch() + with self.assertRaisesRegex(CleanError, "unexpected output entry"): + clean(unexpected_repository) + + child_repository, child = self.repository(root / "child-file") + child.output_root.mkdir() + child.cas_root.touch() + with self.assertRaisesRegex(CleanError, "not a directory"): + clean(child_repository) + + reparse_repository, reparse = self.repository(root / "reparse") + reparse.prepare() + (reparse.cas_root / "entry").mkdir() + with mock.patch( + "core.paths._is_reparse", side_effect=lambda path: Path(path) == reparse.cas_root / "entry" + ), self.assertRaisesRegex(PathSafetyError, "reparse point"): + clean(reparse_repository) + + resolved_repository, resolved = self.repository(root / "resolved") + resolved.output_root.mkdir() + original_resolve = Path.resolve + + def resolve(path: Path, *, strict: bool = False) -> Path: + if path == resolved.output_root: + return resolved.repository / "elsewhere" + return original_resolve(path, strict=strict) + + with mock.patch.object(Path, "resolve", resolve), self.assertRaisesRegex( + CleanError, "does not resolve" + ): + clean(resolved_repository) + + special_repository, special = self.repository(root / "special") + special.prepare() + special_entry = special.cas_root / "special" + special_entry.touch() + original_lstat = os.lstat + + def lstat(path: Path) -> os.stat_result | SimpleNamespace: + return (SimpleNamespace(st_mode=stat.S_IFIFO) + if Path(path) == special_entry else original_lstat(path)) + + with mock.patch("core.clean.os.lstat", side_effect=lstat), self.assertRaisesRegex( + CleanError, "unsupported output entry type" + ): + clean(special_repository) + + def test_invalid_lock_work_entry_mode_and_missing_output_are_safe(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + empty_repository, empty = self.repository(root / "empty") + self.assertEqual(clean(empty_repository), ()) + self.assertFalse(empty.output_root.exists()) + with self.assertRaisesRegex(ValueError, "mode"): + clean(empty_repository, "unknown") + + no_work_repository, no_work = self.repository(root / "no-work") + no_work.output_root.mkdir() + no_work.cas_root.mkdir() + self.assertEqual(clean(no_work_repository, "stale-work"), ()) + + lock_repository, locks = self.repository(root / "bad-lock") + locks.prepare() + (locks.locks_root / "surprise.txt").touch() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + (locks.locks_root / "surprise.txt").unlink() + (locks.locks_root / "bad.lock").touch() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + (locks.locks_root / "bad.lock").unlink() + (locks.locks_root / "directory.lock").mkdir() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + work_repository, work = self.repository(root / "bad-work") + work.prepare() + (work.work_root / "unexpected.txt").touch() + with self.assertRaisesRegex(CleanError, "work entry"): + clean(work_repository, "stale-work") + (work.work_root / "unexpected.txt").unlink() + (work.work_root / "bad name").mkdir() + with self.assertRaisesRegex(CleanError, "work entry"): + clean(work_repository, "stale-work") + + def test_coordination_races_are_rejected_without_claiming_removal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, paths = self.repository(root / "active") + paths.prepare() + with FileLock(paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True), self.assertRaisesRegex( + CleanError, "coordination lock" + ): + clean(repository) + + vanished_repository, vanished = self.repository(root / "vanished") + vanished.prepare() + with mock.patch("core.clean._validate", side_effect=(True, False)): + self.assertEqual(clean(vanished_repository), ()) + + no_cas_repository, no_cas = self.repository(root / "no-cas") + no_cas.work_root.mkdir(parents=True) + self.assertEqual(clean(no_cas_repository), ()) + self.assertEqual( + tuple(no_cas.locks_root.iterdir()), (no_cas.coordination_lock(),) + ) + + def test_cli_prints_exact_removed_paths_and_module_entry_point_is_safe(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + output = StringIO() + with redirect_stdout(output): + self.assertEqual(main((str(repository), "--mode", "stale-work")), 0) + self.assertEqual(output.getvalue(), "") + + paths.run_work("old-run").mkdir() + with redirect_stdout(output): + self.assertEqual(main((str(repository), "--mode", "stale-work")), 0) + self.assertIn(str(paths.run_work("old-run")), output.getvalue()) + + safe = Path(temporary) / "entry" + safe.mkdir() + with mock.patch.object(sys, "argv", ["clean.py", str(safe)]), \ + redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + runpy.run_path(str(BUILD_ROOT / "core/clean.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + self.assertFalse((safe / "out").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_common.py b/tools/build/tests/test_common.py new file mode 100644 index 0000000..34f37f6 --- /dev/null +++ b/tools/build/tests/test_common.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +import tempfile +import unittest + +from core.graph import Graph +from core.node import NodeFactory +from core.render import TemplateRenderer +from graphs.common import BUILD_ROOT, restore_node + + +@dataclass(frozen=True) +class Toolchain: + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +def repository(root: Path) -> Path: + (root / "build/vcpkg/triplets").mkdir(parents=True) + (root / "vcpkg.json").write_text("{}\n", encoding="utf-8") + for flavor in ("", "-asan"): + (root / f"build/vcpkg/triplets/observer-x64-windows-static{flavor}.cmake").write_text( + f"triplet{flavor}\n", encoding="utf-8" + ) + return root + + +class RestoreNodeTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Toolchain]: + repo = repository(root / "repo") + tools = root / "tools" + vcpkg = tools / "vcpkg" + vcpkg.mkdir(parents=True) + pwsh = tools / "pwsh.exe" + pwsh.write_bytes(b"pwsh-one") + (vcpkg / "vcpkg.exe").write_bytes(b"vcpkg-one") + return repo, Toolchain( + pwsh, vcpkg, + (("ZED", "last"), ("Path", str(tools))), + (("pwsh_version", "7.5"), ("unrelated", "first")), + ) + + def test_restore_identity_ignores_parent_factory_and_wrapper_noise(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repo, first = self.fixture(Path(temporary)) + second = replace( + first, + environment=(("PATH", str(first.pwsh.parent)), ("zed", "last")), + identity=(("unrelated", "second"), ("pwsh_version", "wrapper-noise")), + ) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + factories = ( + NodeFactory(renderer, repo / "wrong-one", {"compiler": "one"}, (("NOISE", "one"),)), + NodeFactory(renderer, repo / "wrong-two", {"compiler": "two"}, (("NOISE", "two"),)), + ) + pairs = tuple( + ( + restore_node(repo, first, factories[0], "x64", flavor=flavor), + restore_node(repo, second, factories[1], "x64", flavor=flavor), + ) + for flavor in ("", "asan") + ) + + for left, right in pairs: + self.assertEqual(left, right) + nodes = tuple({node.name: node for pair in pairs for node in pair}.values()) + self.assertEqual(len(nodes), 2) + Graph(nodes, tuple(node.name for node in nodes), {"restore": 1}) + + def test_exact_tool_and_triplet_changes_invalidate_restore(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repo, toolchain = self.fixture(Path(temporary)) + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), repo, {"unrelated": "identity"} + ) + + baseline = restore_node(repo, toolchain, factory, "x64") + toolchain.pwsh.write_bytes(b"pwsh-two") + pwsh_changed = restore_node(repo, toolchain, factory, "x64") + (toolchain.vcpkg_root / "vcpkg.exe").write_bytes(b"vcpkg-two") + vcpkg_changed = restore_node(repo, toolchain, factory, "x64") + (repo / "build/vcpkg/triplets/observer-x64-windows-static.cmake").write_text( + "changed triplet\n", encoding="utf-8" + ) + triplet_changed = restore_node(repo, toolchain, factory, "x64") + + self.assertNotEqual(baseline.uid, pwsh_changed.uid) + self.assertNotEqual(pwsh_changed.uid, vcpkg_changed.uid) + self.assertNotEqual(vcpkg_changed.uid, triplet_changed.uid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_core_coverage.py b/tools/build/tests/test_core_coverage.py new file mode 100644 index 0000000..3e6520b --- /dev/null +++ b/tools/build/tests/test_core_coverage.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.binary_audit import AuditError, require_release_pe # noqa: E402 +from core.execute import ExecutionError, Executor # noqa: E402 +from core.graph import Command, Graph, GraphError, Node # noqa: E402 +from core.node import NodeFactory # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 +from core.render import TemplateRenderer # noqa: E402 +from core.sarif import ( # noqa: E402 + SarifError, + normalize_msvc, + require_clean, +) +from core.store import CasStateError, CasStore # noqa: E402 +from core.toolchain import _existing, _output # noqa: E402 +from core.windows_process import WindowsProcessRunner, _reap # noqa: E402 + + +UID = "0123456789abcdef0123456789abcdef" +RUN_ID = "20260802T000000Z-coverage" + + +def node( + name: str = "leaf", *, inputs: tuple[str, ...] = (), command: Command | None = None +) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "cpu", + command or Command(("tool",)), + inputs, + ) + + +def write_sarif(path: Path, runs: list[object]) -> None: + path.write_text( + json.dumps({"version": "2.1.0", "runs": runs}), encoding="utf-8" + ) + + +class CoreValidationTests(unittest.TestCase): + def test_release_audit_reports_an_unsupported_architecture(self) -> None: + with self.assertRaisesRegex(AuditError, "unsupported.*riscv64") as raised: + require_release_pe("riscv64", "", "", "") + + self.assertIsInstance(raised.exception.__cause__, KeyError) + + def test_command_and_node_reject_ambiguous_runtime_types(self) -> None: + invalid_commands = ( + lambda: Command(()), + lambda: Command((1,)), + lambda: Command(("tool",), env=(("ONLY-KEY",),)), + lambda: Command(("tool",), env=(("KEY", 1),)), + lambda: Command(("tool",), env=(("KEY", "bad\0value"),)), + lambda: Command(("tool",), env=((1, "value"),)), + ) + for constructor in invalid_commands: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() # type: ignore[call-arg] + + invalid_nodes = ( + lambda: Node(1, UID, "cpu", Command(("tool",))), + lambda: Node("leaf", 1, "cpu", Command(("tool",))), + lambda: Node("leaf", UID, 1, Command(("tool",))), + lambda: Node("leaf", UID, "cpu", object()), + lambda: Node("leaf", UID, "cpu", Command(("tool",)), (1,)), + ) + for constructor in invalid_nodes: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() # type: ignore[call-arg] + + def test_graph_rejects_non_integral_pool_capacity_and_sparse_cycle_error( + self, + ) -> None: + with self.assertRaisesRegex(GraphError, "invalid pool capacity"): + Graph((node(),), ("leaf",), {"cpu": "many"}) # type: ignore[dict-item] + + sorter = mock.Mock() + sorter.prepare.side_effect = __import__("graphlib").CycleError("cycle") + with ( + mock.patch("core.graph.TopologicalSorter", return_value=sorter), + self.assertRaisesRegex(GraphError, r"dependency cycle:\s*$"), + ): + Graph((node(),), ("leaf",), {"cpu": 1}) + + def test_node_factory_renders_signs_and_applies_runtime_overrides(self) -> None: + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), + Path(r"C:\repo\default-work"), + {"compiler": "msvc-default"}, + (("DEFAULT", "environment"),), + ) + dependency = node("restore") + variables = { + "pwsh": "pwsh.exe", + "msbuild": r"C:\VS\MSBuild.exe", + "project": r"C:\repo\renpy.vcxproj", + "target": "Build", + "configuration": "Release", + "platform": "x64", + "msbuild_args": [], + } + current = factory.make( + "msbuild.ps1", + "build-renpy-x64", + "cpu", + variables, + files={"renpy.vcxproj": b"project bytes"}, + dependencies=(dependency,), + config={"architecture": "x64"}, + ) + overridden = factory.make( + "msbuild.ps1", + "build-renpy-x64", + "cpu", + variables, + files={"renpy.vcxproj": b"project bytes"}, + dependencies=(dependency,), + config={"architecture": "x64"}, + identity={"compiler": "msvc-override"}, + environment=(("OVERRIDE", "environment"),), + cwd=Path(r"C:\repo\override-work"), + ) + + self.assertEqual(current.inputs, ("restore",)) + self.assertEqual(current.command.env, (("DEFAULT", "environment"),)) + self.assertEqual(current.command.cwd, r"C:\repo\default-work") + self.assertIn(b"C:\\VS\\MSBuild.exe", current.command.stdin) + self.assertEqual(overridden.command.env, (("OVERRIDE", "environment"),)) + self.assertEqual(overridden.command.cwd, r"C:\repo\override-work") + self.assertNotEqual(current.uid, overridden.uid) + + def test_path_policy_rejects_a_candidate_outside_the_repository(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repository" + repository.mkdir() + paths = BuildPaths(repository) + + with self.assertRaisesRegex(PathSafetyError, "outside repository"): + paths._reject_existing_reparse_points(repository.parent) + + def test_cas_cannot_publish_when_entry_exists_without_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + paths = BuildPaths(repository) + paths.prepare() + store = CasStore(paths, RUN_ID) + current = node() + entry = store.paths_for(current) + entry.entry.mkdir() + entry.log.touch() + + with self.assertRaisesRegex(CasStateError, "without output directory"): + store.mark_complete(current) + + self.assertFalse(entry.touch.exists()) + + def test_tool_discovery_explains_missing_paths_and_empty_tool_output(self) -> None: + with self.assertRaisesRegex(FileNotFoundError, "required path not found: None"): + _existing(None) + + completed = subprocess.CompletedProcess(["tool"], 0, stdout=" \r\n", stderr="") + with ( + mock.patch("core.toolchain.subprocess.run", return_value=completed) as run, + self.assertRaisesRegex(RuntimeError, "tool returned empty output: tool"), + ): + _output(["tool"]) + + run.assert_called_once_with( + ["tool"], check=True, capture_output=True, text=True + ) + + +class ExecutorLifecycleTests(unittest.IsolatedAsyncioTestCase): + async def test_executor_rejects_reuse_after_a_successful_build(self) -> None: + current = node() + complete: set[str] = set() + + async def run(built: Node) -> None: + self.assertEqual(built, current) + + executor = Executor( + Graph((current,), (current.name,), {"cpu": 1}), + is_complete=lambda candidate: candidate.uid in complete, + runner=run, + publish=lambda candidate: complete.add(candidate.uid), + ) + await executor.run() + + with self.assertRaisesRegex(ExecutionError, "single-use"): + await executor.run() + + +class SarifFailureTests(unittest.TestCase): + def test_sarif_read_errors_retain_the_report_path_and_cause(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + malformed = root / "malformed.sarif" + malformed.write_text("{", encoding="utf-8") + missing = root / "missing.sarif" + + for path in (malformed, missing): + with self.subTest(path=path), self.assertRaises(SarifError) as raised: + normalize_msvc(path, root / "output.sarif", "analysis/") + self.assertIn(f"cannot read SARIF report {path}", str(raised.exception)) + self.assertIsNotNone(raised.exception.__cause__) + + def test_gate_rejects_a_non_list_or_non_object_results_collection(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, results in (("mapping", {}), ("scalar", ["warning"])): + report = root / f"{name}.sarif" + write_sarif(report, [{"results": results}]) + with self.subTest(results=results), self.assertRaisesRegex( + SarifError, "results must be a list of objects" + ): + require_clean((report,)) + + def test_module_entry_point_exits_successfully_for_a_clean_gate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + report = root / "clean.sarif" + write_sarif(report, [{"results": []}]) + with ( + mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}), + mock.patch.object(sys, "argv", ["sarif.py", "gate", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.sarif"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.sarif", run_name="__main__") + + self.assertEqual(raised.exception.code, 0) + + +class FakeProcess: + def __init__( + self, + *, + communicate_error: BaseException | None = None, + leave_returncode_unset: bool = False, + kill_error: BaseException | None = None, + ) -> None: + self._handle = 301 + self.returncode: int | None = None + self.communicate_error = communicate_error + self.leave_returncode_unset = leave_returncode_unset + self.kill_error = kill_error + self.started = threading.Event() + self.release = threading.Event() + self.release.set() + self.events: list[str] = [] + + def resume(self) -> None: + self.events.append("resume") + + def communicate(self, *, input: bytes) -> tuple[None, None]: + self.events.append(f"communicate:{input!r}") + self.started.set() + self.release.wait(timeout=5) + if self.communicate_error is not None: + raise self.communicate_error + if not self.leave_returncode_unset: + self.returncode = 0 + return None, None + + def kill(self) -> None: + self.events.append("kill") + self.release.set() + if self.kill_error is not None: + raise self.kill_error + + def wait(self) -> int: + self.events.append("wait") + self.returncode = 1 + return self.returncode + + +class FakeJob: + def __init__( + self, + process: FakeProcess, + *, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> None: + self.process = process + self.close_error = close_error + self.terminate_error = terminate_error + + def assign_process(self, process_handle: int) -> None: + self.process.events.append(f"assign:{process_handle}") + + def close(self) -> None: + self.process.events.append("close") + self.process.release.set() + if self.close_error is not None: + raise self.close_error + + def terminate(self) -> None: + self.process.events.append("terminate") + self.process.release.set() + if self.terminate_error is not None: + raise self.terminate_error + + +class WindowsProcessCleanupTests(unittest.IsolatedAsyncioTestCase): + @staticmethod + def patched(process: FakeProcess, job: FakeJob): + return ( + mock.patch("core.windows_process.psutil.Popen", return_value=process), + mock.patch("core.windows_process.WindowsJob", return_value=job), + ) + + async def test_communication_failure_during_reaping_is_reported(self) -> None: + process = FakeProcess(communicate_error=OSError("late pipe failure")) + communication = asyncio.create_task( + asyncio.to_thread(process.communicate, input=b"recipe") + ) + primary = RuntimeError("runner failed") + errors: list[BaseException] = [] + + await _reap(process, communication, errors, primary) + + self.assertEqual([str(error) for error in errors], ["late pipe failure"]) + self.assertIn("wait", process.events) + + async def test_successful_communication_must_set_an_exit_code(self) -> None: + process = FakeProcess(leave_returncode_unset=True) + job = FakeJob(process) + popen, windows_job = self.patched(process, job) + with popen, windows_job, self.assertRaisesRegex( + RuntimeError, "completed without an exit code" + ): + await WindowsProcessRunner().run( + Command((sys.executable,), stdin=b"recipe"), log=io.BytesIO() + ) + + self.assertIn("wait", process.events) + + async def test_every_cleanup_failure_is_visible_after_process_success(self) -> None: + process = FakeProcess(kill_error=OSError("root kill failed")) + job = FakeJob( + process, + close_error=OSError("job close failed"), + terminate_error=OSError("job terminate failed"), + ) + popen, windows_job = self.patched(process, job) + with popen, windows_job, self.assertRaisesRegex( + OSError, "job close failed" + ) as raised: + await WindowsProcessRunner().run( + Command((sys.executable,)), log=io.BytesIO() + ) + + self.assertEqual(process.events[-3:], ["close", "terminate", "kill"]) + notes = raised.exception.__notes__ + self.assertTrue(any("job terminate failed" in note for note in notes)) + self.assertTrue(any("root kill failed" in note for note in notes)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_coverage_config.py b/tools/build/tests/test_coverage_config.py new file mode 100644 index 0000000..d15d128 --- /dev/null +++ b/tools/build/tests/test_coverage_config.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from coverage import Coverage + + +BUILD_ROOT = Path(__file__).resolve().parents[1] + + +class CoverageConfigTests(unittest.TestCase): + def test_first_party_python_gate_requires_every_line_and_branch(self) -> None: + coverage = Coverage(config_file=str(BUILD_ROOT / "pyproject.toml")) + + self.assertTrue(coverage.get_option("run:branch")) + self.assertEqual(coverage.get_option("run:source"), ["."]) + self.assertEqual( + coverage.get_option("run:command_line"), + "-m unittest discover -s tests -p test_*.py", + ) + self.assertEqual(coverage.get_option("report:fail_under"), 100) + self.assertEqual(coverage.get_option("report:exclude_lines"), []) + self.assertEqual( + coverage.get_option("report:include"), + ["core/*", "graphs/*", "driver.py", "main.py"], + ) + self.assertTrue(coverage.get_option("report:show_missing")) + self.assertFalse(coverage.get_option("report:skip_covered")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_cpp_coverage_graph.py b/tools/build/tests/test_cpp_coverage_graph.py new file mode 100644 index 0000000..8b8b497 --- /dev/null +++ b/tools/build/tests/test_cpp_coverage_graph.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +from contextlib import redirect_stderr +import hashlib +import io +import json +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.cpp_coverage import CoverageError, main as coverage_main, require_full_coverage # noqa: E402 +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.coverage import ( # noqa: E402 + CoverageArtifact, + coverage_artifact_graph, + coverage_dependency_discovery_slice, + coverage_graph, +) +from tests import test_instrumented_graph as instrumented_fixture # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("C:/tools/build.exe",)), + inputs, + ) + + +class CppCoverageGraphTests(unittest.TestCase): + def fixture( + self, root: Path, architectures: tuple[str, ...] = ("x64", "x86") + ) -> tuple[Path, Graph, tuple[CoverageArtifact, ...], dict[str, Path]]: + repository = root / "repo" + repository.mkdir() + paths = BuildPaths(repository) + nodes = [] + artifacts = [] + for architecture in architectures: + restore = node(f"restore-vcpkg-{architecture}") + discovery = node(f"coverage-dependencies-{architecture}", inputs=(restore.name,)) + nodes.extend((restore, discovery)) + for name, filename in ( + ("renpy", "renpy.so"), + ("rpgmaker", "rpgmaker.so"), + ("zanzarah", "zanzarah.so"), + ("tests", "tests.exe"), + ): + producer = node( + f"build-{name}-{architecture}-coverage", inputs=(discovery.name,) + ) + nodes.append(producer) + artifacts.append( + CoverageArtifact( + architecture, + name, + producer, + paths.cas(producer.uid, producer.name).output / filename, + ) + ) + upstream = Graph(tuple(nodes), tuple(item.name for item in nodes[1:]), {"build": 8}) + tools = root / "tools" + tools.mkdir() + selected = { + name: tools / name + for name in ("pwsh.exe", "llvm-profdata.exe", "llvm-cov.exe") + } + for path in selected.values(): + path.touch() + return repository, upstream, tuple(artifacts), selected + + def graph(self, root: Path, **options: object) -> Graph: + repository, upstream, artifacts, tools = self.fixture( + root, options.pop("architectures", ("x64", "x86")) # type: ignore[arg-type] + ) + return coverage_artifact_graph( + repository, + upstream, + artifacts, + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": options.pop("profdata_version", "20.1")}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": options.pop("cov_version", "20.1")}, + **options, + ) + + def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.graph(root, test_shards=2, jobs=6, report_jobs=3) + paths = BuildPaths(root / "repo") + + self.assertEqual( + graph.pools, + { + "build": 8, + "coverage-shard": 6, + "coverage-merge": 2, + "coverage-report": 3, + "coverage-gate": 6, + }, + ) + self.assertEqual(graph.targets, ("coverage-x64", "coverage-x86")) + self.assertEqual(len(graph.nodes), 24) + for architecture in ("x64", "x86"): + builds = tuple( + graph.node(f"build-{name}-{architecture}-coverage") + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + shards = tuple( + graph.node(f"coverage-test-{architecture}-{index}") for index in range(2) + ) + for index, shard in enumerate(shards): + self.assertEqual(shard.inputs, tuple(item.name for item in builds)) + self.assertEqual(shard.pool, "coverage-shard") + script = shard.command.stdin.decode("utf-8") + self.assertIn("LLVM_PROFILE_FILE", script) + self.assertIn("coverage-%m-%p.profraw", script) + self.assertIn("'--shard-count'", script) + self.assertIn("'2'", script) + self.assertIn("'--shard-index'", script) + self.assertIn(f"'{index}'", script) + + merge = graph.node(f"coverage-merge-{architecture}") + self.assertEqual(merge.inputs, tuple(item.name for item in shards)) + self.assertEqual(merge.pool, "coverage-merge") + merge_script = merge.command.stdin.decode("utf-8") + self.assertIn("llvm-profdata.exe", merge_script) + self.assertIn("coverage.profdata", merge_script) + self.assertIn("$arguments = @('merge', '-sparse') + $profiles", merge_script) + self.assertIn("llvm-profdata.exe' $arguments", merge_script) + for shard in shards: + self.assertIn(str(paths.cas(shard.uid, shard.name).output), merge_script) + + reports = tuple( + graph.node(f"coverage-{kind}-{architecture}") for kind in ("json", "lcov") + ) + for report in reports: + self.assertEqual(report.inputs, (merge.name, *(item.name for item in builds))) + self.assertEqual(report.pool, "coverage-report") + script = report.command.stdin.decode("utf-8") + self.assertIn("llvm-cov.exe", script) + self.assertIn("--instr-profile", script) + self.assertIn("--ignore-filename-regex", script) + for module in ("renpy.so", "rpgmaker.so", "zanzarah.so"): + self.assertIn(module, script) + self.assertIn("--summary-only", reports[0].command.stdin.decode("utf-8")) + self.assertIn("--format=lcov", reports[1].command.stdin.decode("utf-8")) + + gate = graph.node(f"coverage-{architecture}") + self.assertEqual(gate.inputs, tuple(item.name for item in reports)) + self.assertEqual(gate.pool, "coverage-gate") + self.assertEqual(gate.command.argv[1:4], ("-m", "core.cpp_coverage", "gate")) + self.assertNotIn("threshold", " ".join(gate.command.argv).casefold()) + + def test_tool_identities_invalidate_only_their_nodes_and_semantic_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + + def build(profdata_version: str, cov_version: str) -> Graph: + return coverage_artifact_graph( + repository, + upstream, + artifacts, + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": profdata_version}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": cov_version}, + test_shards=2, + ) + + before, profdata, cov = build("20.1", "20.1"), build("20.2", "20.1"), build("20.1", "20.2") + + for current in before.nodes: + if not current.name.startswith("coverage-") or current.name.startswith( + "coverage-dependencies-" + ): + continue + profdata_partition = not current.name.startswith("coverage-test-") + cov_partition = current.name.startswith(("coverage-json-", "coverage-lcov-")) or current.name == "coverage-x64" + self.assertEqual(profdata_partition, current.uid != profdata.node(current.name).uid, current.name) + self.assertEqual(cov_partition, current.uid != cov.node(current.name).uid, current.name) + + def test_external_corpus_adds_independent_profile_shards_and_signs_only_path_and_nonce(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + corpus = root / "corpus" + moved_corpus = root / "moved-corpus" + corpus.mkdir() + moved_corpus.mkdir() + + def build(selected: Path | None = None, nonce: str = "") -> Graph: + return coverage_artifact_graph( + repository, + upstream, + artifacts, + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": "20.1"}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": "20.1"}, + test_shards=2, + corpus=selected, + run_nonce=nonce, + ) + + baseline = build() + no_corpus = build(None, "ignored") + first = build(corpus, "run-one") + (corpus / "multi-gigabyte-placeholder.bin").write_bytes(b"not signed") + content_changed = build(corpus, "run-one") + rerun = build(corpus, "run-two") + moved = build(moved_corpus, "run-one") + + standard_names = tuple(f"coverage-test-x64-{index}" for index in range(2)) + corpus_names = tuple(f"coverage-corpus-x64-{index}" for index in range(2)) + for name in standard_names: + self.assertEqual(baseline.node(name), no_corpus.node(name)) + self.assertEqual(baseline.node(name), first.node(name)) + self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(name).command.env)) + self.assertFalse(any(node.name.startswith("coverage-corpus-") for node in baseline.nodes)) + for index, name in enumerate(corpus_names): + shard = first.node(name) + self.assertEqual(shard.uid, content_changed.node(name).uid) + self.assertNotEqual(shard.uid, rerun.node(name).uid) + self.assertNotEqual(shard.uid, moved.node(name).uid) + self.assertEqual(shard.inputs, first.node(standard_names[index]).inputs) + self.assertEqual( + dict(shard.command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve()) + ) + script = shard.command.stdin.decode("utf-8") + self.assertIn("'[compatibility]'", script) + self.assertIn("LLVM_PROFILE_FILE", script) + merge = first.node("coverage-merge-x64") + self.assertEqual(merge.inputs, standard_names + corpus_names) + for name in (*standard_names, *corpus_names): + output = repository / "out/cas" / f"{first.node(name).uid}-{name}" / "out" + self.assertIn(str(output), merge.command.stdin.decode()) + self.assertTrue(all( + "OBSERVER_TEST_CORPUS" not in dict(node.command.env) + for node in first.nodes if not node.name.startswith("coverage-corpus-") + )) + + def test_adapter_rejects_incomplete_ambiguous_or_untrusted_build_outputs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + + def invoke(selected: tuple[CoverageArtifact, ...], source: Graph = upstream, **options: object) -> Graph: + return coverage_artifact_graph( + repository, + source, + selected, + pwsh=options.pop("pwsh", tools["pwsh.exe"]), # type: ignore[arg-type] + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": "20.1"}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": "20.1"}, + **options, + ) + + with self.assertRaisesRegex(ValueError, "complete.*set"): + invoke(artifacts[:-1]) + with self.assertRaisesRegex(ValueError, "at least one"): + invoke(()) + with self.assertRaisesRegex(ValueError, "duplicate"): + invoke(artifacts + (artifacts[0],)) + with self.assertRaisesRegex(ValueError, "identity"): + invoke((CoverageArtifact("armv7", "renpy", artifacts[0].producer, artifacts[0].path),)) + with self.assertRaisesRegex(ValueError, "identity"): + invoke((CoverageArtifact("x64", "bad", artifacts[0].producer, artifacts[0].path),)) + + impostor = Node( + artifacts[0].producer.name, + hashlib.md5(b"impostor", usedforsecurity=False).hexdigest(), + "build", + artifacts[0].producer.command, + ("restore-vcpkg-x64",), + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((CoverageArtifact("x64", "renpy", impostor, artifacts[0].path), *artifacts[1:])) + wrong_producer = node("build-renpy-x64-debug", inputs=("restore-vcpkg-x64",)) + wrong_source = Graph(upstream.nodes + (wrong_producer,), upstream.targets, upstream.pools) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke( + ( + CoverageArtifact( + "x64", "renpy", wrong_producer, + BuildPaths(repository).cas(wrong_producer.uid, wrong_producer.name).output / "renpy.so", + ), + *artifacts[1:], + ), + wrong_source, + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((CoverageArtifact("x64", "renpy", artifacts[0].producer, root / "outside.so"), *artifacts[1:])) + wrong_name = artifacts[0].path.with_name("wrong.so") + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((CoverageArtifact("x64", "renpy", artifacts[0].producer, wrong_name), *artifacts[1:])) + + shared = node("detached-shared") + left = node("detached-left", inputs=(shared.name,)) + right = node("detached-right", inputs=(shared.name,)) + detached = node( + "build-renpy-x64-coverage", inputs=(left.name, right.name) + ) + detached_upstream = Graph( + tuple( + detached if current.name == detached.name else current + for current in upstream.nodes + ) + (shared, left, right), + upstream.targets, + upstream.pools, + ) + detached_artifacts = ( + CoverageArtifact( + "x64", "renpy", detached, + BuildPaths(repository).cas(detached.uid, detached.name).output / "renpy.so", + ), + *artifacts[1:], + ) + with self.assertRaisesRegex(ValueError, "restore ancestor"): + invoke(detached_artifacts, detached_upstream) + + for options in ( + {"test_shards": 0}, {"jobs": True}, {"report_jobs": 0}, + ): + with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"): + invoke(artifacts, **options) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(artifacts, pwsh=tools["pwsh.exe"].parent) + conflicting = Graph(upstream.nodes, upstream.targets, dict(upstream.pools) | {"coverage-report": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(artifacts, conflicting, report_jobs=2) + corpus = root / "corpus" + corpus.mkdir() + for nonce in ("", True, "bad\0nonce"): + with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "run nonce"): + invoke(artifacts, corpus=corpus, run_nonce=nonce) + with self.assertRaises(FileNotFoundError): + invoke(artifacts, corpus=root / "missing", run_nonce="run") + file_corpus = root / "corpus.bin" + file_corpus.touch() + with self.assertRaises(NotADirectoryError): + invoke(artifacts, corpus=file_corpus, run_nonce="run") + + def test_complete_graph_discovers_and_builds_coverage_artifacts_itself(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + for name in ("llvm-cov.exe", "llvm-profdata.exe"): + (toolchain.llvm_dir / "bin" / name).write_bytes(b"llvm-v1") + discovery = coverage_dependency_discovery_slice( + repository, toolchain, jobs=4, architectures=("x64",) + ) + graph = coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + jobs=4, + architectures=("x64",), + test_shards=1, + ) + corpus = root / "corpus" + corpus.mkdir() + corpus_graph = coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + jobs=4, + architectures=("x64",), + test_shards=1, + corpus=corpus, + run_nonce="high-level-run", + ) + + self.assertEqual(graph.targets, ("coverage-x64",)) + self.assertEqual( + len(graph.nodes), + len(discovery.nodes) + 4 + 1 + 1 + 2 + 1, + ) + shard = graph.node("coverage-test-x64-0") + self.assertIn("coverage-corpus-x64-0", tuple(node.name for node in corpus_graph.nodes)) + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + build = graph.node(f"build-{project}-x64-coverage") + self.assertIn("'/p:Configuration=Coverage'", build.command.stdin.decode()) + self.assertEqual(shard.inputs.count(build.name), 1) + + def test_complete_graph_hashes_exact_llvm_report_tool_bytes(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + cov = toolchain.llvm_dir / "bin/llvm-cov.exe" + profdata = toolchain.llvm_dir / "bin/llvm-profdata.exe" + cov.write_bytes(b"cov-v1") + profdata.write_bytes(b"profdata-v1") + discovery = coverage_dependency_discovery_slice(repository, toolchain, jobs=4) + manifests = helper.manifests(repository, discovery) + + def build() -> Graph: + return coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + test_shards=1, + ) + + before = build() + profdata.write_bytes(b"profdata-v2") + profdata_changed = build() + profdata.write_bytes(b"profdata-v1") + cov.write_bytes(b"cov-v2") + cov_changed = build() + + for name in ( + "coverage-test-x64-0", + "coverage-merge-x64", + "coverage-json-x64", + "coverage-lcov-x64", + "coverage-x64", + ): + self.assertEqual( + name != "coverage-test-x64-0", + before.node(name).uid != profdata_changed.node(name).uid, + name, + ) + self.assertEqual( + name.startswith(("coverage-json-", "coverage-lcov-")) + or name == "coverage-x64", + before.node(name).uid != cov_changed.node(name).uid, + name, + ) + + +class CppCoverageGateTests(unittest.TestCase): + @staticmethod + def report(lines: tuple[int, int] = (7, 7), branches: tuple[int, int] = (3, 3)) -> dict[str, object]: + return { + "type": "llvm.coverage.json.export", + "data": [{"totals": { + "lines": {"count": lines[0], "covered": lines[1]}, + "branches": {"count": branches[0], "covered": branches[1]}, + }}], + } + + def test_gate_requires_nonempty_exactly_complete_first_party_lines_and_branches(self) -> None: + require_full_coverage(self.report()) + for document, message in ( + (self.report(lines=(7, 6)), "lines"), + (self.report(branches=(3, 2)), "branches"), + (self.report(branches=(0, 0)), "no first-party branches"), + (self.report(lines=(0, 0)), "no first-party lines"), + ): + with self.subTest(message=message), self.assertRaisesRegex(CoverageError, message): + require_full_coverage(document) + + def test_gate_rejects_malformed_or_ambiguous_llvm_reports(self) -> None: + malformed = ( + {}, + {"data": []}, + {"data": [{"totals": {}}, {"totals": {}}]}, + {"data": [{"totals": []}]}, + {"data": [{"totals": {"lines": [], "branches": {"count": 1, "covered": 1}}}]}, + {"data": [{"totals": {"lines": {"count": True, "covered": 1}, "branches": {"count": 1, "covered": 1}}}]}, + {"data": [{"totals": {"lines": {"count": 1, "covered": 2}, "branches": {"count": 1, "covered": 1}}}]}, + ) + for document in malformed: + with self.subTest(document=document), self.assertRaisesRegex(CoverageError, "malformed"): + require_full_coverage(document) + + def test_cli_reads_json_without_a_threshold_override(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + report = Path(temporary) / "coverage.json" + report.write_text(json.dumps(self.report()), encoding="utf-8") + self.assertEqual(coverage_main(("gate", str(report))), 0) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + coverage_main(("gate", str(report), "99")) + + with ( + mock.patch.object(sys, "argv", ["cpp_coverage.py", "gate", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.cpp_coverage"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.cpp_coverage", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_doctor.py b/tools/build/tests/test_doctor.py new file mode 100644 index 0000000..fd00c33 --- /dev/null +++ b/tools/build/tests/test_doctor.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +import runpy +from types import SimpleNamespace +import sys +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import core.doctor as doctor # noqa: E402 + + +TOOLCHAIN = SimpleNamespace(identity=( + ("msbuild_version", "17.14"), ("vc_tools_version", "14.44"), + ("clang_tidy_version", "19.1"), ("windows_sdk_version", "10.0"), +)) +SOURCE = SimpleNamespace(identity=( + ("pwsh_version", "7.5"), ("clang_format_version", "19.1"), + ("cppcheck_version", "2.18"), ("psscriptanalyzer_version", "1.24"), +)) +QUALITY = object() +RUNTIMES = object() + + +class DoctorTests(unittest.TestCase): + def patches(self) -> tuple[mock._patch, ...]: + return ( + mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE), + mock.patch.object(doctor, "discover_quality_tools", return_value=QUALITY), + mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES), + ) + + def test_complete_report_is_deterministic_and_main_renders_plain_tsv(self) -> None: + with self.patches()[0] as msvc, self.patches()[1] as source, \ + self.patches()[2] as quality, self.patches()[3] as runtimes: + report = doctor.doctor_report() + self.assertEqual(report, doctor.doctor_report()) + self.assertEqual( + report, + ( + doctor.Probe("python", "OK", "3.14.6"), + doctor.Probe("msvc", "OK", "MSBuild=17.14, MSVC=14.44, LLVM=19.1, SDK=10.0"), + doctor.Probe("source-tools", "OK", "PowerShell=7.5, clang-format=19.1, Cppcheck=2.18, PSScriptAnalyzer=1.24"), + doctor.Probe("quality-tools", "OK", "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH"), + doctor.Probe("sanitizer-runtimes", "OK", "ASan x86/x64, UBSan x64"), + ), + ) + self.assertEqual(msvc.call_count, 2) + self.assertEqual(source.call_args, mock.call(TOOLCHAIN)) + self.assertEqual(quality.call_args, mock.call(TOOLCHAIN)) + self.assertEqual(runtimes.call_args, mock.call(TOOLCHAIN)) + + output = StringIO() + with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output): + self.assertEqual(doctor.main(()), 0) + self.assertEqual( + output.getvalue(), + "probe\tstatus\tdetail\n" + "".join( + f"{item.name}\t{item.status}\t{item.detail}\n" for item in report + ), + ) + + def test_failures_are_concise_independent_and_make_main_fail(self) -> None: + failing_quality = RuntimeError("quality\n missing") + with ( + mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE), + mock.patch.object(doctor, "discover_quality_tools", side_effect=failing_quality), + mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES), + ): + report = doctor.doctor_report() + self.assertEqual([item.status for item in report], ["OK", "OK", "OK", "MISSING", "OK"]) + self.assertEqual(report[3].detail, "quality missing") + output = StringIO() + with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output): + self.assertEqual(doctor.main(()), 1) + self.assertIn("quality-tools\tMISSING\tquality missing\n", output.getvalue()) + + def test_missing_python_and_msvc_still_report_every_probe(self) -> None: + missing = RuntimeError() + with ( + mock.patch.object(doctor.sys, "version_info", (3, 14, 5)), + mock.patch.object(doctor, "discover_msvc_toolchain", side_effect=missing), + mock.patch.object(doctor, "discover_source_tools") as source, + mock.patch.object(doctor, "discover_quality_tools") as quality, + mock.patch.object(doctor, "resolve_sanitizer_runtimes") as runtimes, + ): + report = doctor.doctor_report() + self.assertEqual([item.status for item in report], ["MISSING"] * 5) + self.assertIn("requires ==3.14.6, running 3.14.5", report[0].detail) + self.assertEqual(report[1].detail, "RuntimeError") + self.assertTrue(all(item.detail == "MSVC toolchain unavailable" for item in report[2:])) + source.assert_not_called() + quality.assert_not_called() + runtimes.assert_not_called() + + def test_module_entry_point_exits_with_main_result(self) -> None: + patches = ( + mock.patch("core.toolchain.discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch("core.source_tools.discover_source_tools", return_value=SOURCE), + mock.patch("core.quality_tools.discover_quality_tools", return_value=QUALITY), + mock.patch("core.quality_tools.resolve_sanitizer_runtimes", return_value=RUNTIMES), + mock.patch.object(sys, "argv", ["doctor.py"]), + ) + with patches[0], patches[1], patches[2], patches[3], patches[4], \ + redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + runpy.run_path(str(BUILD_ROOT / "core/doctor.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_driver.py b/tools/build/tests/test_driver.py new file mode 100644 index 0000000..377e2a6 --- /dev/null +++ b/tools/build/tests/test_driver.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.quality_tools import ResolvedDirectory, ResolvedTool # noqa: E402 +import driver # noqa: E402 + + +MODULES = ("renpy", "rpgmaker", "zanzarah") + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "slot", + Command(("true",)), + inputs, + ) + + +def discovery_graph(architectures: tuple[str, ...]) -> Graph: + restores = tuple(node(f"restore-vcpkg-{architecture}") for architecture in architectures) + discovered = tuple( + node(f"discover-{architecture}", inputs=(restore.name,)) + for architecture, restore in zip(architectures, restores, strict=True) + ) + return Graph(restores + discovered, tuple(item.name for item in discovered), {"restore": 1, "slot": 4}) + + +def native_result(_repository: Path, _toolchain: object, **options: object) -> Graph: + discovery = options["discovery"] + architectures = options["architectures"] + configurations = options["configurations"] + builds = tuple( + node(f"build-{project}-{architecture}-{configuration.lower()}") + for architecture in architectures + for configuration in configurations + for project in (*MODULES, "tests") + ) + if options["include_leak_probe"]: + builds += (node("build-leak-probe-x64-release"),) + return Graph(discovery.nodes + builds, tuple(item.name for item in builds), {"restore": 1, "slot": 4}) + + +class FakeRuntime: + def __init__(self, repository: Path, run_id: str) -> None: + self.repository = repository + self.run_id = run_id + self.executed: list[Graph] = [] + self.store = SimpleNamespace( + paths_for=lambda current: SimpleNamespace( + output=repository / "out/cas" / current.name / "out" + ) + ) + + def executor(self, graph: Graph) -> FakeRuntime: + self.executed.append(graph) + return self + + async def run(self) -> None: + return None + + +class DriverTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary.name) / "repo" + self.repository.mkdir() + self.toolchain = SimpleNamespace(identity=(), environment=()) + self.runtime_patch = mock.patch.object(driver, "BuildRuntime", FakeRuntime) + self.runtime_patch.start() + + def tearDown(self) -> None: + self.runtime_patch.stop() + self.temporary.cleanup() + + def session(self, jobs: int | None = 4) -> driver.Driver: + return driver.Driver(self.repository, "run-1", self.toolchain, jobs=jobs) + + def tool(self, name: str) -> ResolvedTool: + path = self.repository / f"tools/{name}.exe" + return ResolvedTool(path, (("name", name),)) + + @mock.patch.object(driver.psutil, "cpu_count", return_value=None) + def test_session_owns_one_canonical_repository_runtime_and_validates_jobs( + self, cpu_count: mock.Mock + ) -> None: + session = self.session(None) + self.assertEqual(session.repository, self.repository.resolve()) + self.assertEqual(session.jobs, 1) + self.assertEqual(session.runtime.run_id, "run-1") + cpu_count.assert_called_once_with() + with self.assertRaisesRegex(ValueError, "positive"): + self.session(0) + with self.assertRaisesRegex(ValueError, "positive"): + self.session(True) + + async def test_restore_build_test_source_analysis_and_fuzz_use_shared_staging(self) -> None: + session = self.session() + source_tools = object() + corpus = self.repository / "corpus" + corpus.mkdir() + + def restore_node(_repository: Path, _toolchain: object, _factory: object, + architecture: str, *, flavor: str = "") -> Node: + suffix = f"-{flavor}" if flavor else "" + name = f"restore-vcpkg{suffix}-{architecture}" + return Node( + name, + hashlib.md5(f"{flavor}-{architecture}".encode(), usedforsecurity=False).hexdigest(), + "restore", Command(("true",)), (), + ) + + with ( + mock.patch.object(driver, "restore_node", side_effect=restore_node) as restore, + mock.patch.object( + driver, "native_dependency_discovery_slice", + side_effect=lambda _repository, _toolchain, **options: discovery_graph(options["architectures"]), + ) as native_discovery, + mock.patch.object(driver, "native_graph", side_effect=native_result) as native, + mock.patch.object(driver, "load_dependency_manifests", return_value={"unit": b"{}"}) as manifests, + mock.patch.object( + driver, "source_checks_graph", return_value=Graph((node("source"),), ("source",), {"slot": 4}) + ) as source, + mock.patch.object( + driver, "analysis_discovery_slice", return_value=discovery_graph(("x64",)) + ) as analysis_discovery, + mock.patch.object( + driver, "analysis_slice", return_value=Graph((node("analysis"),), ("analysis",), {"slot": 4}) + ) as analysis, + mock.patch.object( + driver, "fuzz_dependency_discovery_slice", return_value=discovery_graph(("x64",)) + ) as fuzz_discovery, + mock.patch.object( + driver, "fuzz_graph", return_value=Graph((node("fuzz"),), ("fuzz",), {"slot": 2}) + ) as fuzz, + mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require, + ): + await session.restore(("x64",), ("", "asan")) + await session.build(("x64",), ("Debug", "Release")) + await session.test(("x64",), ("Debug",), test_shards=7, corpus=corpus, run_nonce="nonce") + await session.source_checks(("x64",), source_tools) + await session.compiler_analysis(("x64",)) + await session.fuzz( + run_nonce="fuzz-run", seconds=9, fuzz_jobs=3, + targets=("pickle", "renpy"), + ) + + self.assertEqual(restore.call_count, 2) + self.assertEqual( + session.runtime.executed[0].targets, + ("restore-vcpkg-x64", "restore-vcpkg-asan-x64"), + ) + self.assertEqual(native_discovery.call_count, 2) + self.assertEqual(manifests.call_count, 4) + self.assertEqual(native.call_args_list[0].kwargs["runnable_architectures"], ()) + self.assertFalse(native.call_args_list[0].kwargs["include_leak_probe"]) + self.assertEqual(native.call_args_list[1].kwargs["runnable_architectures"], ("x64",)) + self.assertEqual(native.call_args_list[1].kwargs["test_shards"], 7) + self.assertEqual(native.call_args_list[1].kwargs["corpus"], corpus) + self.assertEqual(native.call_args_list[1].kwargs["run_nonce"], "nonce") + source.assert_called_once_with(self.repository, source_tools, jobs=4, architectures=("x64",)) + analysis_discovery.assert_called_once() + self.assertEqual(analysis.call_args.kwargs["manifests"], {"unit": b"{}"}) + fuzz_discovery.assert_called_once() + self.assertEqual(fuzz.call_args.kwargs["run_nonce"], "fuzz-run") + self.assertEqual(fuzz.call_args.kwargs["seconds"], 9) + self.assertEqual(fuzz.call_args.kwargs["fuzz_jobs"], 3) + self.assertEqual(fuzz_discovery.call_args.kwargs["targets"], ("pickle", "renpy")) + self.assertEqual(fuzz.call_args.kwargs["targets"], ("pickle", "renpy")) + self.assertEqual(require.call_args_list, [mock.call(("x64",)), mock.call(("x64",))]) + + async def test_coverage_sanitizers_and_python_coverage_use_staged_graph_apis(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + asan_tools = tuple((architecture, self.tool(f"asan-{architecture}")) + for architecture in ("x86", "x64")) + ubsan_file = self.tool("ubsan") + ubsan = ResolvedDirectory(self.repository / "ubsan", (ubsan_file,)) + + with ( + mock.patch.object(driver, "coverage_dependency_discovery_slice", + return_value=discovery_graph(("x64",))) as coverage_discovery, + mock.patch.object(driver, "coverage_graph", + return_value=Graph((node("coverage"),), ("coverage",), {"slot": 4})) as coverage, + mock.patch.object(driver, "sanitizer_dependency_discovery_slice", + side_effect=lambda *_args, **_kwargs: discovery_graph(("x64",))) as sanitizer_discovery, + mock.patch.object(driver, "sanitizer_graph", + side_effect=lambda *_args, **kwargs: Graph( + (node(kwargs["selections"][0][0]),), + (kwargs["selections"][0][0],), {"slot": 4} + )) as sanitizer, + mock.patch.object(driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 1})) as python_graph, + mock.patch.object(driver, "resolve_asan_runtimes", return_value=asan_tools) as resolve_asan, + mock.patch.object(driver, "resolve_ubsan_runtime", return_value=ubsan) as resolve_ubsan, + mock.patch.object(driver, "load_dependency_manifests", return_value={"tu": b"{}"}) as manifests, + mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require, + ): + await session.test_coverage( + ("x64",), test_shards=7, report_jobs=3, + corpus=corpus, run_nonce="coverage-run", + ) + await session.test_asan(("x86", "x64"), test_shards=5) + await session.test_ubsan(("x64",), test_shards=6) + await session.python_coverage() + + coverage_discovery.assert_called_once_with( + self.repository, self.toolchain, jobs=4, architectures=("x64",) + ) + self.assertEqual(coverage.call_args.kwargs["manifests"], {"tu": b"{}"}) + self.assertEqual(coverage.call_args.kwargs["test_shards"], 7) + self.assertEqual(coverage.call_args.kwargs["report_jobs"], 3) + self.assertEqual(coverage.call_args.kwargs["corpus"], corpus) + self.assertEqual(coverage.call_args.kwargs["run_nonce"], "coverage-run") + self.assertEqual(sanitizer_discovery.call_count, 2) + self.assertEqual(sanitizer.call_count, 2) + asan_call, ubsan_call = sanitizer.call_args_list + self.assertEqual(asan_call.kwargs["selections"], (("asan", "x86"), ("asan", "x64"))) + self.assertEqual(tuple(item.architecture for item in asan_call.kwargs["asan_runtimes"]), + ("x86", "x64")) + self.assertIsNone(asan_call.kwargs["llvm_runtime"]) + self.assertEqual(ubsan_call.kwargs["selections"], (("ubsan", "x64"),)) + self.assertEqual(ubsan_call.kwargs["llvm_runtime"], ubsan.path) + self.assertEqual(ubsan_call.kwargs["llvm_runtime_identity"], dict(ubsan.identity)) + resolve_asan.assert_called_once_with(self.toolchain, ("x86", "x64")) + resolve_ubsan.assert_called_once_with(self.toolchain) + self.assertEqual(manifests.call_count, 3) + self.assertEqual(require.call_args_list, + [mock.call(("x64",)), mock.call(("x86", "x64")), mock.call(("x64",))]) + python_graph.assert_called_once_with(self.repository) + + async def test_quality_commands_reject_invalid_contracts_before_discovery(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + with ( + mock.patch.object(driver, "coverage_dependency_discovery_slice") as coverage_discovery, + mock.patch.object(driver, "sanitizer_dependency_discovery_slice") as sanitizer_discovery, + ): + for invalid_nonce in ("", "bad\0nonce", 7): + with self.subTest(run_nonce=invalid_nonce), self.assertRaisesRegex( + ValueError, "corpus run nonce" + ): + await session.test_coverage(corpus=corpus, run_nonce=invalid_nonce) + with mock.patch.object( + driver, "require_runnable", side_effect=RuntimeError("cannot run") + ), self.assertRaisesRegex(RuntimeError, "cannot run"): + await session.test_coverage(("x64",)) + with self.assertRaisesRegex(ValueError, "ASan does not support arm64"): + await session.test_asan(("arm64",)) + with self.assertRaisesRegex(ValueError, "UBSan does not support x86"): + await session.test_ubsan(("x86",)) + coverage_discovery.assert_not_called() + sanitizer_discovery.assert_not_called() + + async def test_release_audit_package_and_leaks_map_exact_native_artifacts(self) -> None: + session = self.session() + dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh")) + + def native_discovery(_repository: Path, _toolchain: object, **options: object) -> Graph: + return discovery_graph(options["architectures"]) + + def audit_result(_repository: Path, upstream: Graph, artifacts: object, **_options: object) -> Graph: + gates = tuple( + node(f"audit-pe-{item.architecture}-{item.module}", inputs=(item.producer.name,)) + for item in artifacts + ) + return Graph(upstream.nodes + gates, tuple(item.name for item in gates), upstream.pools | {"audit": 4}) + + package_result = Graph((node("package-manifest"),), ("package-manifest",), {"slot": 4}) + leak_result = Graph((node("leak"),), ("leak",), {"slot": 4}) + expected_packages = (self.repository / "one.zip",) + with ( + mock.patch.object(driver, "native_dependency_discovery_slice", side_effect=native_discovery), + mock.patch.object(driver, "native_graph", side_effect=native_result) as native, + mock.patch.object(driver, "load_dependency_manifests", return_value={}), + mock.patch.object(driver, "audit_graph", side_effect=audit_result) as audit, + mock.patch.object(driver, "package_graph", return_value=package_result) as package, + mock.patch.object(driver, "package_outputs", return_value=expected_packages) as outputs, + mock.patch.object(driver, "leak_graph", return_value=leak_result) as leak, + mock.patch.object(driver, "runnable_architectures", return_value=("x86", "x64")) as runnable, + mock.patch.object(driver, "require_runnable", return_value=("x64",)) as require, + ): + await session.audit(("x64",), dumpbin=dumpbin, binskim=binskim, binskim_jobs=2) + result = await session.package( + ("x86", "x64", "arm64"), dumpbin=dumpbin, binskim=binskim, binskim_jobs=2 + ) + await session.test_leaks( + run_nonce="leak-run", dumpbin=dumpbin, binskim=binskim, umdh=umdh, + binskim_jobs=2, warmup=2, iterations=3, windows=4, tolerance_bytes=5, + session_jobs=1, diff_jobs=2, + ) + + self.assertEqual(result, expected_packages) + self.assertEqual(native.call_count, 3) + self.assertTrue(all(call.kwargs["configurations"] == ("Release",) for call in native.call_args_list)) + self.assertEqual([call.kwargs["include_leak_probe"] for call in native.call_args_list], [False, False, True]) + self.assertTrue(all(call.kwargs["runnable_architectures"] == () for call in native.call_args_list)) + self.assertEqual(audit.call_count, 3) + first_artifacts = tuple(audit.call_args_list[0].args[2]) + self.assertEqual([item.module for item in first_artifacts], list(MODULES)) + self.assertTrue(all(item.path.name == f"{item.module}.so" for item in first_artifacts)) + self.assertEqual(audit.call_args_list[0].kwargs["dumpbin_identity"], {"name": "dumpbin"}) + self.assertEqual(audit.call_args_list[0].kwargs["binskim_identity"], {"name": "binskim"}) + package_artifacts = tuple(package.call_args.args[2]) + self.assertEqual(len(package_artifacts), 9) + self.assertTrue(all(item.symbols.name == f"{item.module}.pdb" for item in package_artifacts)) + smokes = tuple(package.call_args.kwargs["smoke_tests"]) + self.assertEqual([item.architecture for item in smokes], ["x86", "x64"]) + self.assertTrue(all(item.executable.name == "tests.exe" for item in smokes)) + runnable.assert_called_once_with(("x86", "x64", "arm64")) + outputs.assert_called_once_with(self.repository, package_result) + leak_artifacts = tuple(leak.call_args.args[2]) + self.assertEqual([item.module for item in leak_artifacts], ["leak-probe", *MODULES]) + self.assertEqual(leak_artifacts[0].path.name, "leak-probe.exe") + self.assertEqual(leak.call_args.kwargs["umdh"], umdh.path) + self.assertEqual(leak.call_args.kwargs["umdh_identity"], {"name": "umdh"}) + self.assertEqual(leak.call_args.kwargs["run_nonce"], "leak-run") + self.assertEqual(leak.call_args.kwargs["warmup"], 2) + self.assertEqual(leak.call_args.kwargs["iterations"], 3) + self.assertEqual(leak.call_args.kwargs["windows"], 4) + self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 5) + self.assertEqual(leak.call_args.kwargs["session_jobs"], 1) + self.assertEqual(leak.call_args.kwargs["diff_jobs"], 2) + require.assert_called_once_with(("x64",)) + + async def test_verify_runs_one_discovery_union_then_one_parallel_family_union(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh")) + ubsan = ResolvedDirectory(self.repository / "ubsan", (self.tool("ubsan"),)) + route = SimpleNamespace( + runnable=("x86", "x64"), coverage=("x64",), asan=("x86", "x64"), + ubsan=("x64",), run_x64_specialists=True, + ) + + def discovered(_repository: Path, _toolchain: object, **options: object) -> Graph: + architectures = options.get("architectures", ("x64",)) + return discovery_graph(architectures) + + def family(name: str, upstream: Graph | None = None) -> Graph: + pools = dict(upstream.pools) if upstream else {"slot": 4} + nodes = upstream.nodes if upstream else () + marker = node(name) + return Graph(nodes + (marker,), (marker.name,), pools) + + source_tools = mock.Mock(return_value="source-tools") + asan = mock.Mock(return_value=( + ("x86", self.tool("asan-x86")), ("x64", self.tool("asan-x64")), + )) + resolve_ubsan = mock.Mock(return_value=ubsan) + native = mock.Mock(side_effect=native_result) + source = mock.Mock(return_value=family("source")) + coverage = mock.Mock(return_value=family("coverage")) + sanitizer = mock.Mock(return_value=family("sanitizer")) + fuzz = mock.Mock(return_value=family("fuzz")) + package = mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream) + ) + leak = mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("leak", upstream) + ) + with mock.patch.multiple( + driver, + verify_route=mock.Mock(return_value=route), + discover_source_tools=source_tools, + resolve_dumpbin=mock.Mock(return_value=dumpbin), + resolve_binskim=mock.Mock(return_value=binskim), + resolve_umdh=mock.Mock(return_value=umdh), + resolve_asan_runtimes=asan, + resolve_ubsan_runtime=resolve_ubsan, + analysis_discovery_slice=mock.Mock(side_effect=discovered), + native_dependency_discovery_slice=mock.Mock(side_effect=discovered), + coverage_dependency_discovery_slice=mock.Mock(side_effect=discovered), + sanitizer_dependency_discovery_slice=mock.Mock(side_effect=discovered), + fuzz_dependency_discovery_slice=mock.Mock(side_effect=discovered), + load_dependency_manifests=mock.Mock(return_value={"tu": b"{}"}), + native_graph=native, + analysis_slice=mock.Mock(return_value=family("analysis")), + source_checks_graph=source, + python_coverage_graph=mock.Mock(return_value=family("python")), + coverage_graph=coverage, + sanitizer_graph=sanitizer, + fuzz_graph=fuzz, + audit_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream) + ), + package_graph=package, + leak_graph=leak, + ): + await session.verify( + ("x86", "x64", "arm64"), corpus=corpus, run_nonce="verify-run", + fuzz_seconds=7, test_shards=5, warmup=2, iterations=3, + windows=4, tolerance_bytes=6, + ) + + self.assertEqual(len(session.runtime.executed), 2) + final = session.runtime.executed[-1] + self.assertTrue({"analysis", "source", "python", "coverage", "sanitizer", "fuzz", + "package", "leak"}.issubset(final.targets)) + self.assertEqual(native.call_args.kwargs["configurations"], ("Debug", "Release")) + self.assertEqual(native.call_args.kwargs["runnable_architectures"], ("x86", "x64")) + self.assertTrue(native.call_args.kwargs["include_leak_probe"]) + self.assertEqual(coverage.call_args.kwargs["corpus"], corpus) + self.assertEqual(coverage.call_args.kwargs["run_nonce"], "verify-run") + self.assertEqual(sanitizer.call_args.kwargs["selections"], ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64"), + )) + self.assertEqual(fuzz.call_args.kwargs["seconds"], 7) + self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 6) + self.assertEqual( + [item.architecture for item in package.call_args.kwargs["smoke_tests"]], + ["x86", "x64"], + ) + source_tools.assert_called_once_with(self.toolchain) + source.assert_called_once() + asan.assert_called_once_with(self.toolchain, ("x86", "x64")) + resolve_ubsan.assert_called_once_with(self.toolchain) + + async def test_verify_omits_nonrunnable_specialists_but_keeps_build_audit_package(self) -> None: + session = self.session() + route = SimpleNamespace( + runnable=(), coverage=(), asan=(), ubsan=(), run_x64_specialists=False, + ) + discovery = discovery_graph(("arm64",)) + + def family(name: str, upstream: Graph | None = None) -> Graph: + pools = dict(upstream.pools) if upstream else {"slot": 4} + nodes = upstream.nodes if upstream else () + marker = node(name) + return Graph(nodes + (marker,), (marker.name,), pools) + + unused = { + name: mock.Mock() for name in ( + "resolve_umdh", "resolve_asan_runtimes", "resolve_ubsan_runtime", + "coverage_dependency_discovery_slice", "sanitizer_dependency_discovery_slice", + "fuzz_dependency_discovery_slice", "coverage_graph", "sanitizer_graph", + "fuzz_graph", "leak_graph", + ) + } + native = mock.Mock(side_effect=native_result) + with mock.patch.multiple( + driver, + verify_route=mock.Mock(return_value=route), + discover_source_tools=mock.Mock(return_value="source-tools"), + resolve_dumpbin=mock.Mock(return_value=self.tool("dumpbin")), + resolve_binskim=mock.Mock(return_value=self.tool("binskim")), + analysis_discovery_slice=mock.Mock(return_value=discovery), + native_dependency_discovery_slice=mock.Mock(return_value=discovery), + load_dependency_manifests=mock.Mock(return_value={}), + native_graph=native, + analysis_slice=mock.Mock(return_value=family("analysis")), + source_checks_graph=mock.Mock(return_value=family("source")), + python_coverage_graph=mock.Mock(return_value=family("python")), + audit_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream) + ), + package_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream) + ), + **unused, + ): + await session.verify(("arm64",), run_nonce="verify-run") + + self.assertEqual(len(session.runtime.executed), 2) + self.assertTrue({"analysis", "source", "python", "package"}.issubset( + session.runtime.executed[-1].targets + )) + self.assertFalse(native.call_args.kwargs["include_leak_probe"]) + for current in unused.values(): + current.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_execute.py b/tools/build/tests/test_execute.py new file mode 100644 index 0000000..bc13b3c --- /dev/null +++ b/tools/build/tests/test_execute.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +import hashlib +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.execute import ExecutionError, Executor # noqa: E402 +from core.graph import Command, Graph, GraphError, Node # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = (), pool: str = "cpu") -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool, + Command(("synthetic", name)), + inputs, + ) + + +class ExecutorTests(unittest.IsolatedAsyncioTestCase): + async def test_explicit_target_subset_returns_none_and_skips_other_targets(self) -> None: + graph = Graph( + (node("selected"), node("other")), + ("selected", "other"), + {"cpu": 2}, + ) + complete: set[str] = set() + calls: list[str] = [] + + async def runner(current: Node) -> None: + calls.append(current.name) + + result = await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run(("selected",)) + + self.assertIsNone(result) + self.assertEqual(calls, ["selected"]) + + with self.assertRaisesRegex(GraphError, "target.*empty"): + await Executor( + graph, + is_complete=lambda _current: False, + runner=runner, + publish=lambda _current: None, + ).run(()) + + async def test_demand_traversal_deduplicates_a_shared_dependency(self) -> None: + graph = Graph( + ( + node("shared"), + node("left", inputs=("shared",)), + node("right", inputs=("shared",)), + node("unreachable"), + ), + ("left", "right"), + {"cpu": 4}, + ) + complete: set[str] = set() + calls: list[str] = [] + + async def runner(current: Node) -> None: + calls.append(current.name) + await asyncio.sleep(0.01) + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(calls.count("shared"), 1) + self.assertCountEqual(calls, ["shared", "left", "right"]) + self.assertNotIn("unreachable", calls) + + async def test_named_pool_limits_concurrency(self) -> None: + graph = Graph( + (node("one"), node("two"), node("three")), + ("one", "two", "three"), + {"cpu": 2}, + ) + complete: set[str] = set() + active = 0 + maximum = 0 + + async def runner(_current: Node) -> None: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + try: + await asyncio.sleep(0.02) + finally: + active -= 1 + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(maximum, 2) + + async def test_shared_slot_pool_limits_total_cross_family_concurrency(self) -> None: + graph = Graph( + (node("compile", pool="slot"),) + tuple( + node(f"audit-{index}", pool="audit") for index in range(4) + ), + ("compile",) + tuple(f"audit-{index}" for index in range(4)), + {"slot": 2, "audit": 4}, + ) + complete: set[str] = set() + active = maximum = 0 + + async def runner(_current: Node) -> None: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + try: + await asyncio.sleep(0.02) + finally: + active -= 1 + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(maximum, 2) + + async def test_warm_target_cache_skips_dependencies(self) -> None: + graph = Graph( + (node("dependency"), node("target", inputs=("dependency",))), + ("target",), + {"cpu": 2}, + ) + + async def runner(_current: Node) -> None: + raise AssertionError("warm target and its dependencies must not run") + + await Executor( + graph, + is_complete=lambda current: current.name == "target", + runner=runner, + publish=lambda _current: None, + ).run() + + async def test_external_lock_rechecks_cache_before_dependencies(self) -> None: + graph = Graph( + (node("dependency"), node("target", inputs=("dependency",))), + ("target",), + {"cpu": 1}, + ) + complete = False + events: list[str] = [] + + @asynccontextmanager + async def lock(current: Node): + nonlocal complete + events.append(f"enter:{current.name}") + complete = current.name == "target" + try: + yield + finally: + events.append(f"leave:{current.name}") + + async def runner(_current: Node) -> None: + raise AssertionError("cache was completed while waiting for the lock") + + await Executor( + graph, + is_complete=lambda current: complete and current.name == "target", + runner=runner, + publish=lambda _current: None, + acquire_lock=lock, + ).run() + + self.assertEqual(events, ["enter:target", "leave:target"]) + + async def test_runner_must_publish_completion(self) -> None: + graph = Graph((node("broken"),), ("broken",), {"cpu": 1}) + + async def runner(_current: Node) -> None: + pass + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda _current: False, + runner=runner, + publish=lambda _current: None, + ).run() + self.assertIn("broken", repr(raised.exception.subgroup(ExecutionError))) + + async def test_task_group_cancels_running_siblings_on_first_failure(self) -> None: + graph = Graph( + (node("failure"), node("slow")), + ("failure", "slow"), + {"cpu": 2}, + ) + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + + async def runner(current: Node) -> None: + if current.name == "slow": + slow_started.set() + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + slow_cancelled.set() + raise + await slow_started.wait() + raise RuntimeError("expected failure") + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda _current: False, + runner=runner, + publish=lambda _current: None, + ).run() + + self.assertIn("expected failure", repr(raised.exception.subgroup(RuntimeError))) + self.assertTrue(slow_cancelled.is_set()) + + async def test_completion_predicate_is_synchronous_and_returns_bool(self) -> None: + graph = Graph((node("invalid"),), ("invalid",), {"cpu": 1}) + + async def runner(_current: Node) -> None: + pass + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda _current: "yes", # type: ignore[arg-type,return-value] + runner=runner, + publish=lambda _current: None, + ).run() + self.assertIn("did not return bool", repr(raised.exception.subgroup(ExecutionError))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_fuzz_graph.py b/tools/build/tests/test_fuzz_graph.py new file mode 100644 index 0000000..6472fe5 --- /dev/null +++ b/tools/build/tests/test_fuzz_graph.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.paths import BuildPaths # noqa: E402 +from graphs.fuzz import ( # noqa: E402 + FuzzCorpusArtifact, + fuzz_corpus_artifacts, + fuzz_dependency_discovery_slice, + fuzz_graph, +) + + +TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah") + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class FuzzGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + +""" + files = { + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "build/ObserverFuzz.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + "build/vcpkg/triplets/observer-x64-windows-static-asan.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + } + for target in TARGETS: + files[f"build/projects/fuzz-{target}.vcxproj"] = project.format(target=target) + files[f"src/fuzz/{target}.cpp"] = f'#include "{target}.h"\n' + files[f"src/fuzz/{target}.h"] = "#pragma once\n" + files[f"src/fuzz/corpus/{target}/seed.hex"] = "00 7f ff\n" + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + llvm = tools / "llvm" + llvm_bin = llvm / "bin" + runtime = llvm / "lib/clang/19/lib/windows" + vcpkg = tools / "vcpkg" + llvm_bin.mkdir(parents=True) + runtime.mkdir(parents=True) + vcpkg.mkdir() + (vcpkg / "vcpkg.exe").touch() + msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + for name in ("clang-cl.exe", "clang-scan-deps.exe"): + (llvm_bin / name).touch() + return FakeToolchain( + msbuild, + pwsh, + vcpkg, + llvm, + (("PATH", str(tools)), ("VCPKG_ROOT", "stale")), + {"msbuild": "17.14", "llvm": "19.1.5", "vcpkg": "2026.07"}, + ) + + def graph(self, repository: Path, toolchain: FakeToolchain, **kwargs): + jobs = kwargs.get("jobs", 2) + targets = kwargs.get("targets", TARGETS) + discovery = fuzz_dependency_discovery_slice( + repository, toolchain, jobs=jobs, targets=targets + ) + manifests = {} + for target, name in zip(targets, discovery.targets, strict=True): + source = repository / f"src/fuzz/{target}.cpp" + header = repository / f"src/fuzz/{target}.h" + manifests[name] = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str(header.resolve())], + } + } + ).encode() + return discovery, fuzz_graph( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + **kwargs, + ) + + def test_discovery_names_are_confined_to_the_fuzz_configuration_namespace(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = fuzz_dependency_discovery_slice( + repository, self.toolchain(root), targets=("pickle",) + ) + + self.assertTrue(discovery.targets[0].startswith( + "discover-dependencies-x64-fuzz-fuzz-pickle-" + )) + scanned = discovery.node(discovery.targets[0]) + self.assertEqual(len(scanned.inputs), 1) + self.assertTrue(scanned.inputs[0].startswith( + "capture-clang-command-x64-fuzz-fuzz-pickle-" + )) + + def test_target_subset_is_exact_and_preserves_existing_node_uids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _full_discovery, full = self.graph( + repository, toolchain, run_nonce="same" + ) + subset_discovery, subset = self.graph( + repository, toolchain, run_nonce="same", + targets=("pickle", "zanzarah"), + ) + + self.assertEqual( + subset.targets, ("fuzz-x64-pickle", "fuzz-x64-zanzarah") + ) + self.assertEqual( + fuzz_corpus_artifacts(subset), + tuple( + FuzzCorpusArtifact(target, subset.node(f"run-fuzz-x64-{target}").uid) + for target in ("pickle", "zanzarah") + ), + ) + self.assertTrue(all("renpy" not in node.name and "rpgmaker" not in node.name + for node in subset.nodes)) + self.assertEqual(len(subset_discovery.targets), 2) + for current in subset.nodes: + self.assertEqual(current.uid, full.node(current.name).uid, current.name) + + def test_targets_must_be_a_unique_nonempty_supported_subset(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + for targets, message in ( + ((), "must not be empty"), + (("pickle", "pickle"), "duplicate fuzz target: pickle"), + (("unknown",), "unsupported fuzz target: unknown"), + ): + with self.subTest(targets=targets), self.assertRaisesRegex(ValueError, message): + fuzz_dependency_discovery_slice( + repository, toolchain, targets=targets + ) + + def test_four_targets_are_independent_build_replay_and_bounded_run_branches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.graph( + repository, self.toolchain(root), run_nonce="test-run", seconds=37, + jobs=3, fuzz_jobs=2, + ) + + self.assertEqual(graph.pools, {"build": 3, "fuzz": 2, "restore": 1, "slot": 3}) + self.assertEqual(graph.targets, tuple(f"fuzz-x64-{target}" for target in TARGETS)) + self.assertEqual( + fuzz_corpus_artifacts(graph), + tuple( + FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid) + for target in TARGETS + ), + ) + self.assertEqual(len(graph.nodes), 1 + len(TARGETS) * 6) + restore = graph.node("restore-vcpkg-asan-x64") + self.assertEqual(restore.pool, "restore") + self.assertIn("observer-x64-windows-static-asan", restore.command.stdin.decode()) + self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg")) + + for target in TARGETS: + build = graph.node(f"build-fuzz-x64-{target}") + replay = graph.node(f"replay-fuzz-x64-{target}") + run = graph.node(f"run-fuzz-x64-{target}") + gate = graph.node(f"fuzz-x64-{target}") + expected_discovery = next( + name for name in discovery.targets if f"-fuzz-{target}-" in name + ) + self.assertEqual(build.inputs, (expected_discovery,)) + self.assertEqual(replay.inputs, (build.name,)) + self.assertEqual(run.inputs, (replay.name,)) + self.assertEqual(gate.inputs, (run.name,)) + self.assertEqual( + (build.pool, replay.pool, run.pool, gate.pool), + ("build", "fuzz", "fuzz", "fuzz"), + ) + self.assertFalse(any(other in " ".join(run.inputs) for other in TARGETS if other != target)) + + def test_build_and_run_recipes_preserve_the_current_fuzzer_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, graph = self.graph( + repository, toolchain, run_nonce="test-run", seconds=37 + ) + + build = graph.node("build-fuzz-x64-pickle") + replay = graph.node("replay-fuzz-x64-pickle") + run = graph.node("run-fuzz-x64-pickle") + gate = graph.node("fuzz-x64-pickle") + + build_script = build.command.stdin.decode("utf-8") + self.assertIn(str(repository / "build/projects/fuzz-pickle.vcxproj"), build_script) + self.assertIn("'/t:Build'", build_script) + self.assertIn("'/p:Configuration=Fuzz'", build_script) + self.assertIn("'/p:Platform=x64'", build_script) + self.assertRegex(build_script, r"'/p:VcpkgInstalledDir=.*\\'") + self.assertIn("'/p:LLVMInstallDir=", build_script) + self.assertIn("'/p:LLVMRuntimeDir=", build_script) + self.assertIn("fuzz-pickle.exe", build_script) + + replay_script = replay.command.stdin.decode("utf-8") + run_script = run.command.stdin.decode("utf-8") + for script in (replay_script, run_script): + self.assertIn("$buildDir 'corpus'", script) + self.assertIn("FromHexString", script) + self.assertIn("-max_len=262144", script) + self.assertIn("-rss_limit_mb=1024", script) + self.assertIn("-timeout=10", script) + self.assertNotIn("-max_total_time", replay_script) + self.assertIn("Get-ChildItem -LiteralPath $corpus", replay_script) + self.assertIn("-max_total_time=37", run_script) + self.assertIn("-use_value_profile=1", run_script) + self.assertIn("$outDir 'corpus'", run_script) + self.assertIn("status.txt", run_script) + self.assertIn("$PSNativeCommandUseErrorActionPreference = $false", run_script) + self.assertNotIn("Invoke-Checked $fuzzer", run_script) + gate_script = gate.command.stdin.decode("utf-8") + self.assertIn("status.txt", gate_script) + self.assertIn("Fuzzer exited with code", gate_script) + runtime = str(toolchain.llvm_dir / "lib/clang/19/lib/windows") + self.assertTrue(dict(run.command.env)["PATH"].startswith(runtime)) + self.assertEqual( + dict(run.command.env)["ASAN_OPTIONS"], + "halt_on_error=1:alloc_dealloc_mismatch=1", + ) + parser = ( + "$tokens=$null;$errors=$null;" + "[Management.Automation.Language.Parser]::ParseInput(" + "[Console]::In.ReadToEnd(),[ref]$tokens,[ref]$errors)|Out-Null;" + "if($errors.Count){$errors|ForEach-Object ToString;exit 1}" + ) + parsed = subprocess.run( + [shutil.which("pwsh"), "-NoLogo", "-NoProfile", "-Command", parser], + input="\n".join((build_script, replay_script, run_script, gate_script)), + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(0, parsed.returncode, parsed.stderr or parsed.stdout) + + def test_one_seed_change_invalidates_only_its_replay_and_run_branch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="test-run") + (repository / "src/fuzz/corpus/pickle/seed.hex").write_text("01\n", encoding="utf-8") + _discovery, after = self.graph(repository, toolchain, run_nonce="test-run") + + changed = { + "replay-fuzz-x64-pickle", + "run-fuzz-x64-pickle", + "fuzz-x64-pickle", + } + for node in before.nodes: + comparison = after.node(node.name) + if node.name in changed: + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + + def test_actual_msbuild_contract_rejects_every_non_x64_architecture(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + for architecture in ("x86", "arm64"): + with self.subTest(architecture=architecture): + with self.assertRaisesRegex(ValueError, "x64-only"): + fuzz_graph( + repository, toolchain, run_nonce="test-run", + discovery=None, manifests={}, + architectures=(architecture,), + ) + + with self.assertRaisesRegex(ValueError, "run nonce"): + fuzz_graph(repository, toolchain, run_nonce="", discovery=None, manifests={}) + with self.assertRaisesRegex(ValueError, "dependency discovery is required"): + fuzz_graph( + repository, toolchain, run_nonce="test-run", + discovery=None, manifests={}, + ) + + def test_build_requires_one_compiler_manifest_per_project_tu(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + discovery = fuzz_dependency_discovery_slice(repository, toolchain) + + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + fuzz_graph( + repository, toolchain, discovery=discovery, manifests={}, + run_nonce="test-run", + ) + + def test_run_nonce_invalidates_only_the_four_bounded_runs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="run-one") + _discovery, after = self.graph(repository, toolchain, run_nonce="run-two") + + for node in before.nodes: + comparison = after.node(node.name) + if node.name.startswith(("run-fuzz-x64-", "fuzz-x64-")): + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + + def test_prior_published_corpus_seeds_and_signs_only_its_next_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="same") + producer = before.node("run-fuzz-x64-pickle") + cas = BuildPaths(repository).cas(producer.uid, producer.name) + (cas.output / "corpus").mkdir(parents=True) + (cas.output / "corpus/evolved").write_bytes(b"first") + cas.log.write_text("green\n", encoding="utf-8") + cas.touch.touch() + artifact = FuzzCorpusArtifact("pickle", producer.uid) + + _discovery, seeded = self.graph( + repository, toolchain, run_nonce="same", prior_corpora=(artifact,) + ) + (cas.output / "corpus/evolved").write_bytes(b"second") + _discovery, content_changed = self.graph( + repository, toolchain, run_nonce="same", prior_corpora=(artifact,) + ) + + changed = {"run-fuzz-x64-pickle", "fuzz-x64-pickle"} + for node in before.nodes: + comparison = seeded.node(node.name) + if node.name in changed: + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + self.assertNotEqual( + seeded.node("run-fuzz-x64-pickle").uid, + content_changed.node("run-fuzz-x64-pickle").uid, + ) + script = seeded.node("run-fuzz-x64-pickle").command.stdin.decode() + self.assertIn(str(cas.output / "corpus"), script) + self.assertIn("Copy-Item", script) + + def test_prior_corpus_must_be_unique_and_complete_canonical_cas(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + missing = FuzzCorpusArtifact("pickle", "0" * 32) + with self.assertRaisesRegex(FileNotFoundError, "not published"): + self.graph( + repository, toolchain, run_nonce="run", prior_corpora=(missing,) + ) + with self.assertRaisesRegex(ValueError, "duplicate prior corpus"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(missing, missing), + ) + with self.assertRaisesRegex(ValueError, "unsupported fuzz corpus target"): + FuzzCorpusArtifact("unknown", "0" * 32) + for invalid_uid in (42, "invalid"): + with self.subTest(invalid_uid=invalid_uid), self.assertRaisesRegex( + ValueError, "canonical MD5" + ): + FuzzCorpusArtifact("pickle", invalid_uid) + + def test_prior_corpus_rejects_empty_or_nonfile_content(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + paths = BuildPaths(repository) + empty = paths.cas("1" * 32, "run-fuzz-x64-pickle") + (empty.output / "corpus").mkdir(parents=True) + empty.log.touch() + empty.touch.touch() + with self.assertRaisesRegex(ValueError, "corpus is empty"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(FuzzCorpusArtifact("pickle", "1" * 32),), + ) + + nonfile = paths.cas("2" * 32, "run-fuzz-x64-pickle") + (nonfile.output / "corpus/directory").mkdir(parents=True) + nonfile.log.touch() + nonfile.touch.touch() + with self.assertRaisesRegex(ValueError, "contains a non-file"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(FuzzCorpusArtifact("pickle", "2" * 32),), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_graph.py b/tools/build/tests/test_graph.py new file mode 100644 index 0000000..58b0922 --- /dev/null +++ b/tools/build/tests/test_graph.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, GraphError, Node, merge_graphs # noqa: E402 + + +def node( + name: str, + *, + inputs: tuple[str, ...] = (), + pool: str = "cpu", +) -> Node: + return Node( + name=name, + uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool=pool, + command=Command(("tool", name)), + inputs=inputs, + ) + + +class GraphTests(unittest.TestCase): + def test_graphs_merge_shared_exact_nodes_pools_and_targets(self) -> None: + shared = node("shared") + first = Graph( + (shared, node("first", inputs=(shared.name,))), + ("first",), + {"cpu": 2}, + ) + second = Graph( + (shared, node("second", inputs=(shared.name,), pool="io")), + ("shared", "second"), + {"cpu": 2, "io": 1}, + ) + + merged = merge_graphs(first, second) + + self.assertEqual(tuple(current.name for current in merged.nodes), ("shared", "first", "second")) + self.assertEqual(merged.targets, ("first", "shared", "second")) + self.assertEqual(dict(merged.pools), {"cpu": 2, "io": 1}) + + def test_graph_merge_rejects_missing_inputs_and_conflicting_shared_contracts(self) -> None: + with self.assertRaisesRegex(GraphError, "at least one graph"): + merge_graphs() + + first = Graph((node("shared"),), ("shared",), {"cpu": 1}) + conflicting_node = Node("shared", "f" * 32, "cpu", Command(("tool",))) + second = Graph((conflicting_node,), ("shared",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "conflicting node definition.*shared"): + merge_graphs(first, second) + + third = Graph((node("other"),), ("other",), {"cpu": 2}) + with self.assertRaisesRegex(GraphError, "conflicting pool capacity.*cpu"): + merge_graphs(first, third) + + def test_edges_and_targets_are_direct_node_names(self) -> None: + producer = node("producer") + consumer = node("consumer", inputs=("producer",)) + graph = Graph((consumer, producer), ("consumer",), {"cpu": 2}) + + self.assertEqual(consumer.inputs, ("producer",)) + self.assertEqual(graph.node("producer"), producer) + self.assertEqual(graph.dependencies_of("consumer"), (producer,)) + + def test_unknown_dependencies_targets_and_duplicate_names_are_rejected(self) -> None: + with self.assertRaisesRegex(GraphError, "duplicate node name"): + Graph((node("same"), node("same")), ("same",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "unknown dependency.*missing"): + Graph( + (node("consumer", inputs=("missing",)),), + ("consumer",), + {"cpu": 1}, + ) + with self.assertRaisesRegex(GraphError, "unknown target.*missing"): + Graph((node("only"),), ("missing",), {"cpu": 1}) + + def test_cycles_are_rejected_before_execution(self) -> None: + with self.assertRaisesRegex(GraphError, "cycle.*first.*second.*first"): + Graph( + ( + node("second", inputs=("first",)), + node("first", inputs=("second",)), + ), + ("first",), + {"cpu": 1}, + ) + + def test_each_node_selects_one_known_positive_pool(self) -> None: + with self.assertRaisesRegex(GraphError, "unknown pool.*missing"): + Graph((node("only", pool="missing"),), ("only",), {"cpu": 1}) + for invalid in (0, -1, True): + with self.subTest(invalid=invalid), self.assertRaisesRegex( + GraphError, "invalid pool capacity" + ): + Graph((node("only"),), ("only",), {"cpu": invalid}) # type: ignore[dict-item] + + def test_node_identity_and_command_are_safe_runtime_descriptors(self) -> None: + command = Command( + ("pwsh", "argument with spaces", "&literal", ""), + env=(("ZED", "last"), ("ALPHA", "first")), + cwd="work/a directory", + stdin=b"Write-Output 'literal'\r\n", + ) + current = Node("safe-name.1", "0" * 32, "cpu", command) + + self.assertEqual(current.command.env, (("ALPHA", "first"), ("ZED", "last"))) + self.assertEqual(current.command.stdin, b"Write-Output 'literal'\r\n") + self.assertEqual(current.command.argv[-1], "") + with self.assertRaisesRegex(GraphError, "uid.*lowercase MD5"): + Node("safe", "NOT-A-UID", "cpu", Command(("tool",))) + with self.assertRaisesRegex(GraphError, "node name.*128"): + node("a" * 129) + + def test_ambiguous_command_and_node_data_are_rejected(self) -> None: + invalid_commands = ( + lambda: Command(()), + lambda: Command(("bad\0argument",)), + lambda: Command(("tool",), env=(("PATH", "1"), ("Path", "2"))), + lambda: Command(("tool",), cwd="bad\0cwd"), + lambda: Command(("tool",), stdin="not bytes"), # type: ignore[arg-type] + ) + for constructor in invalid_commands: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() + + with self.assertRaisesRegex(GraphError, "duplicate dependencies"): + Node("safe", "0" * 32, "cpu", Command(("tool",)), ("same", "same")) + + def test_graph_container_and_lookups_are_explicit(self) -> None: + only = node("only") + invalid_graphs = ( + lambda: Graph((), ("only",), {"cpu": 1}), + lambda: Graph((only,), (), {"cpu": 1}), + lambda: Graph((only,), ("only", "only"), {"cpu": 1}), + lambda: Graph((object(),), ("only",), {"cpu": 1}), # type: ignore[arg-type] + lambda: Graph((only,), ("only",), {"": 1}), + ) + for constructor in invalid_graphs: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() + + graph = Graph((only,), ("only",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "unknown node"): + graph.node("missing") + with self.assertRaisesRegex(GraphError, "unknown node"): + graph.dependencies_of("missing") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_graph_main_coverage.py b/tools/build/tests/test_graph_main_coverage.py new file mode 100644 index 0000000..3d54385 --- /dev/null +++ b/tools/build/tests/test_graph_main_coverage.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs import analysis, common, fuzz, native # noqa: E402 + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +def write(root: Path, relative: str, content: str = "") -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def toolchain(root: Path, *, runtime: bool = False) -> FakeToolchain: + tools = root / "tools" + vcpkg = tools / "vcpkg" + llvm = tools / "llvm" + vcpkg.mkdir(parents=True) + llvm.mkdir() + if runtime: + (llvm / "lib/clang/19/lib/windows").mkdir(parents=True) + for path in (tools / "MSBuild.exe", tools / "pwsh.exe", vcpkg / "vcpkg.exe"): + path.touch() + return FakeToolchain( + tools / "MSBuild.exe", + tools / "pwsh.exe", + vcpkg, + llvm, + (("PATH", str(tools)),), + {"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1"}, + ) + + +def native_repository(root: Path) -> Path: + project = """ + + +""" + for relative in ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + ): + write(root, relative, "\n") + write(root, "vcpkg.json", "{}\n") + write(root, "build/vcpkg/triplets/observer-x64-windows-static.cmake") + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"): + write(root, f"src/{name}.cpp", "int value;\n") + write(root, f"build/projects/{name}.vcxproj", project.format(name=name)) + return root + + +class AnalysisCoverageTests(unittest.TestCase): + def test_compiler_manifest_accepts_only_exact_relevant_dependency_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + source = write(repository, "src/module/source.cpp", "int value;\n") + first_party = write(repository, "src/shared.h", "first-party\n") + package_root = repository / "out/cas/package/out" + package = write(package_root, "include/package.h", "package\n") + system = write(repository, "sdk/system.h", "system\n") + content = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [ + str(first_party.resolve()), + str(first_party.resolve()), + str(package.resolve()), + str(system.resolve()), + ], + } + } + ).encode() + + files = analysis.dependency_inputs(repository, package_root, source, content) + + self.assertEqual( + set(files), + {"compiler/dependencies.json", "src/module/source.cpp", "src/shared.h", "vcpkg/include/package.h"}, + ) + + def test_invalid_or_mismatched_compiler_manifests_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + source = write(repository, "src/source.cpp", "int value;\n") + package = repository / "out/cas/package/out" + package.mkdir(parents=True) + invalid = ( + b"{", + b"{}", + json.dumps({"Data": {"Source": 1, "Includes": []}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": {}}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": [None]}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": ["relative.h"]}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": [str(repository / 'src/missing.h')]}}).encode(), + ) + for content in invalid: + with self.subTest(content=content), self.assertRaisesRegex(ValueError, "invalid MSVC"): + analysis.dependency_inputs(repository, package, source, content) + mismatch = json.dumps( + {"Data": {"Source": str(write(repository, "src/other.cpp")), "Includes": []}} + ).encode() + with self.assertRaisesRegex(ValueError, "source mismatch"): + analysis.dependency_inputs(repository, package, source, mismatch) + + def test_project_inventory_ignores_empty_items_and_rejects_external_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + write( + repository, + "build/projects/empty.vcxproj", + '' + "", + ) + self.assertEqual(analysis._projects(repository), ()) + + write( + repository, + "build/projects/empty.vcxproj", + '' + '', + ) + with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"): + analysis._projects(repository) + + def test_fuzz_analysis_signs_fuzz_props_and_unknown_architecture_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = root / "repo" + for relative in ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/ObserverFuzz.props", + ): + write(repository, relative, "\n") + write(repository, "build/ObserverNativeAnalysis.ruleset", "\n") + write(repository, ".clang-tidy", "Checks: bugprone-*\n") + write(repository, "vcpkg.json", "{}\n") + write(repository, "build/vcpkg/triplets/observer-x64-windows-static.cmake") + write(repository, "src/fuzz/pickle.cpp", "int value;\n") + write( + repository, + "build/projects/fuzz-pickle.vcxproj", + '' + '' + "", + ) + fake = toolchain(root) + + before_discovery = analysis.analysis_discovery_slice(repository, fake) + name = before_discovery.targets[0] + manifest = json.dumps( + {"Data": {"Source": str((repository / "src/fuzz/pickle.cpp").resolve()), "Includes": []}} + ).encode() + before = analysis.analysis_slice( + repository, fake, discovery=before_discovery, manifests={name: manifest} + ) + write(repository, "build/ObserverFuzz.props", "\n") + after_discovery = analysis.analysis_discovery_slice(repository, fake) + after = analysis.analysis_slice( + repository, fake, discovery=after_discovery, manifests={name: manifest} + ) + node_name = "analyze-msvc-x64-fuzz-pickle-fuzz.pickle" + + self.assertNotEqual(before.node(node_name).uid, after.node(node_name).uid) + with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"): + analysis.analysis_discovery_slice(repository, fake, architectures=("mips",)) + with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"): + analysis.analysis_slice( + repository, + fake, + discovery=after_discovery, + manifests={name: manifest}, + architectures=("mips",), + ) + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + analysis.analysis_slice( + repository, fake, discovery=after_discovery, manifests={} + ) + + +class CommonAndFuzzCoverageTests(unittest.TestCase): + def test_environment_without_vcpkg_root_preserves_path_and_extra_values(self) -> None: + fake = type("Toolchain", (), {"environment": (("Path", "base"),)})() + + environment = dict( + common.tool_environment( + fake, + prepend_path=Path("runtime"), + extra=(("MODE", "checked"),), + ) + ) + + self.assertEqual(environment["PATH"], f"runtime{common.os.pathsep}base") + self.assertEqual(environment["MODE"], "checked") + self.assertNotIn("VCPKG_ROOT", environment) + + def test_missing_sanitizer_runtime_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = toolchain(root) + + with self.assertRaisesRegex(FileNotFoundError, "sanitizer runtimes"): + fuzz._runtime(fake) + + def test_fuzzer_project_rejects_unsigned_source_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + for relative in fuzz._COMMON: + write(repository, relative, "\n") + write( + repository, + "build/projects/fuzz-pickle.vcxproj", + '' + '' + "", + ) + + with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"): + fuzz._project_files( + repository, "pickle", Path("unused"), object(), {} + ) + + def test_empty_seed_corpus_and_nonpositive_duration_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + (repository / "src/fuzz/corpus/pickle").mkdir(parents=True) + with self.assertRaisesRegex(FileNotFoundError, "no checked-in fuzzer seeds"): + fuzz._seed_files(repository, "pickle") + + with self.assertRaisesRegex(ValueError, "fuzz seconds must be positive"): + fuzz.fuzz_graph( + Path("unused"), object(), discovery=None, manifests={}, + run_nonce="run", seconds=0, + ) + + +class NativeCoverageTests(unittest.TestCase): + def test_project_metadata_rejects_external_input_and_ignores_empty_items(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + project = write( + repository, + "build/projects/sample.vcxproj", + '' + "", + ) + + inputs = native._project_inputs(repository, project) + + self.assertEqual(inputs[-1], "build/projects/sample.vcxproj") + self.assertEqual(len(inputs), len(native._COMMON_INPUTS) + 1) + with self.assertRaisesRegex(ValueError, "unsupported project input"): + native._relative(repository, "C:\\external.cpp", project) + + def test_invalid_job_capacity_and_nonrunnable_release_leak_probe_contract(self) -> None: + with self.assertRaisesRegex(ValueError, "jobs must be a positive integer"): + native.native_graph( + Path("unused"), object(), jobs=0, runnable_architectures=() + ) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = native_repository(root / "repo") + fake = toolchain(root) + discovery = native.native_dependency_discovery_slice( + repository, fake, configurations=("Release",), include_leak_probe=True + ) + projects = ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") + manifests = { + name: json.dumps( + { + "Data": { + "Source": str((repository / f"src/{next(project for project in projects if name.endswith('-' + project))}.cpp").resolve()), + "Includes": [], + } + } + ).encode() + for name in discovery.targets + } + graph = native.native_graph( + repository, + fake, + discovery=discovery, + manifests=manifests, + architectures=("x64",), + configurations=("Release",), + runnable_architectures=(), + test_shards=1, + include_leak_probe=True, + ) + + self.assertIn("build-leak-probe-x64-release", graph.targets) + self.assertFalse(any(name.startswith("test-shard-") for name in graph.targets)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_host.py b/tools/build/tests/test_host.py new file mode 100644 index 0000000..e45ac4a --- /dev/null +++ b/tools/build/tests/test_host.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import unittest + +from core.host import ( + detect_host_architecture, + require_runnable, + runnable_architectures, + verify_route, +) + + +class HostTests(unittest.TestCase): + def test_common_machine_names_are_canonicalized(self) -> None: + for machine, expected in ( + ("AMD64", "x64"), + ("x86_64", "x64"), + ("ARM64", "arm64"), + ("aarch64", "arm64"), + ("x86", "x86"), + ("i686", "x86"), + ): + with self.subTest(machine=machine): + self.assertEqual(detect_host_architecture(machine), expected) + + def test_unknown_machine_is_rejected(self) -> None: + with self.assertRaisesRegex(RuntimeError, "unsupported Windows host architecture"): + detect_host_architecture("mips64") + + def test_runnable_matrix_matches_windows_emulation_contract(self) -> None: + requested = ("x86", "x64", "arm64") + self.assertEqual(runnable_architectures(requested, "x86"), ("x86",)) + self.assertEqual(runnable_architectures(requested, "x64"), ("x86", "x64")) + self.assertEqual(runnable_architectures(requested, "arm64"), requested) + + def test_invalid_requested_and_host_architectures_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported requested architecture"): + runnable_architectures(("sparc",), "x64") + with self.assertRaisesRegex(ValueError, "unsupported host architecture"): + runnable_architectures(("x64",), "sparc") + + def test_test_command_contract_rejects_nonrunnable_requests(self) -> None: + with self.assertRaisesRegex(RuntimeError, "cannot run arm64 tests on x64 host"): + require_runnable(("x86", "arm64"), "x64") + + self.assertEqual(require_runnable(("x86", "x64"), "x64"), ("x86", "x64")) + + def test_verify_route_selects_host_capable_specialists(self) -> None: + route = verify_route(("x86", "x64", "arm64"), "x64") + + self.assertEqual(route.runnable, ("x86", "x64")) + self.assertEqual(route.coverage, ("x64",)) + self.assertEqual(route.asan, ("x86", "x64")) + self.assertEqual(route.ubsan, ("x64",)) + self.assertTrue(route.run_x64_specialists) + self.assertEqual( + [(item.gate, item.architecture) for item in route.deferred], + [("tests", "arm64"), ("package-runtime", "arm64")], + ) + self.assertTrue(all(item.reason for item in route.deferred)) + + def test_verify_route_defers_nonrunnable_specialists_explicitly(self) -> None: + route = verify_route(("x64",), "x86") + + self.assertEqual(route.runnable, ()) + self.assertEqual(route.coverage, ()) + self.assertEqual(route.asan, ()) + self.assertEqual(route.ubsan, ()) + self.assertFalse(route.run_x64_specialists) + self.assertEqual( + [item.gate for item in route.deferred], + ["tests", "package-runtime", "coverage", "asan", "ubsan", "leaks", "fuzz"], + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_instrumented_graph.py b/tools/build/tests/test_instrumented_graph.py new file mode 100644 index 0000000..6e334bd --- /dev/null +++ b/tools/build/tests/test_instrumented_graph.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.paths import BuildPaths # noqa: E402 +from core.graph import Graph, Node # noqa: E402 +from graphs.instrumented import ( # noqa: E402 + InstrumentedVariant, + instrumented_build_slice, + instrumented_dependency_discovery_slice, +) +from graphs.analysis import ( # noqa: E402 + clang_dependency_discovery_slice, + dependency_node_name, +) + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class InstrumentedBuildGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + {definition} + +""" + files = { + "src/renpy.cpp": '#include "renpy.h"\n', + "src/renpy.h": "#pragma once\n", + "src/rpgmaker.cpp": '#include "rpgmaker.h"\n', + "src/rpgmaker.h": "#pragma once\n", + "src/zanzarah.cpp": '#include "zanzarah.h"\n', + "src/zanzarah.h": "#pragma once\n", + "src/tests.cpp": '#include "tests.h"\n', + "src/tests.h": "#pragma once\n", + "src/leak-probe.cpp": '#include "tests.h"\n', + "src/renpy.def": "EXPORTS\n OpenStorage\n", + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + } + for architecture in ("x86", "x64"): + for flavor in ("", "-asan"): + files[ + f"build/vcpkg/triplets/observer-{architecture}-windows-static{flavor}.cmake" + ] = "set(VCPKG_LIBRARY_LINKAGE static)\n" + for name in ("renpy", "rpgmaker", "zanzarah", "tests"): + definition = ( + "" + "$(RepositoryRoot)src\\renpy.def" + "" + if name == "renpy" + else ( + "" + "" + if name == "rpgmaker" + else "" + ) + ) + files[f"build/projects/{name}.vcxproj"] = project.format( + name=name, definition=definition + ) + files["build/projects/leak-probe.vcxproj"] = project.format( + name="leak-probe", definition="" + ) + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "tools" + tools.mkdir() + vcpkg = tools / "vcpkg" + vcpkg.mkdir() + (vcpkg / "vcpkg.exe").touch() + llvm = tools / "llvm" + (llvm / "bin").mkdir(parents=True) + (llvm / "bin/clang-cl.exe").write_bytes(b"clang-v1") + (llvm / "bin/clang-scan-deps.exe").write_bytes(b"scan-v1") + msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + return FakeToolchain( + msbuild, pwsh, vcpkg, llvm, + (("PATH", str(tools)),), + {"msbuild": "17.14", "msvc": "14.44", "llvm": "20.1"}, + ) + + def llvm_runtime(self, root: Path) -> Path: + runtime = root / "llvm-runtime" + runtime.mkdir() + for name in ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", + ): + (runtime / name).touch() + return runtime + + def variants(self, runtime: Path) -> tuple[InstrumentedVariant, ...]: + return ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant("asan", "x86"), + InstrumentedVariant("asan", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, + {"standalone": "sha256:a", "cxx": "sha256:b"}, + ), + ) + + def manifests(self, repository: Path, discovery) -> dict[str, bytes]: + result = {} + for target in discovery.targets: + project = next( + name + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + if f"-{name}-" in target + ) + result[target] = json.dumps( + { + "Data": { + "Source": str((repository / f"src/{project}.cpp").resolve()), + "Includes": [str((repository / f"src/{project}.h").resolve())], + } + } + ).encode() + return result + + def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + variants = self.variants(self.llvm_runtime(root)) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants, jobs=8 + ) + graph, artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=self.manifests(repository, discovery), + variants=variants, + jobs=8, + ) + paths = BuildPaths(repository) + + names = tuple(item.name for item in discovery.nodes) + self.assertEqual(names.count("restore-vcpkg-x64"), 1) + self.assertEqual(names.count("restore-vcpkg-asan-x86"), 1) + self.assertEqual(names.count("restore-vcpkg-asan-x64"), 1) + self.assertEqual(len(discovery.targets), 16) + clang_capture_count = 4 * sum( + item.kind in {"coverage", "ubsan"} for item in variants + ) + self.assertEqual( + len(discovery.nodes), + 3 + len(discovery.targets) + clang_capture_count, + ) + coverage_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-coverage-renpy-" in name) + ) + ubsan_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-ubsan-renpy-" in name) + ) + asan_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-asan-renpy-" in name) + ).command.stdin.decode("utf-8") + for normalized in (coverage_discovery, ubsan_discovery): + self.assertEqual(len(normalized.inputs), 1) + raw = discovery.node(normalized.inputs[0]) + script = raw.command.stdin.decode("utf-8") + self.assertIn("/p:ObserverClangCommandPath=", script) + self.assertIn("'/p:LLVMInstallDir=", script) + self.assertNotIn("PlatformToolset=v143", script) + self.assertNotIn("ObserverSourceDependenciesPath", script) + self.assertEqual( + normalized.command.argv[1:4], + ("-m", "core.clang_dependencies", "scan"), + ) + self.assertNotIn("PlatformToolset=v143", asan_discovery) + self.assertEqual(len(graph.nodes), len(discovery.nodes) + len(artifacts)) + self.assertEqual(len(graph.targets), 16) + self.assertEqual(graph.pools, {"restore": 1, "slot": 8}) + self.assertEqual(len(artifacts), 16) + + for variant in variants: + restore = ( + f"restore-vcpkg-asan-{variant.architecture}" + if variant.kind == "asan" + else f"restore-vcpkg-{variant.architecture}" + ) + for project, filename in ( + ("renpy", "renpy.so"), + ("rpgmaker", "rpgmaker.so"), + ("zanzarah", "zanzarah.so"), + ("tests", "tests.exe"), + ): + build = graph.node( + f"build-{project}-{variant.architecture}-{variant.kind}" + ) + self.assertEqual(len(build.inputs), 1) + self.assertIn( + f"-{variant.architecture}-{variant.kind}-{project}-", build.inputs[0] + ) + script = build.command.stdin.decode("utf-8") + self.assertIn(f"'/p:Configuration={variant.configuration}'", script) + self.assertIn("'/m:1'", script) + self.assertIn("'/p:BuildProjectReferences=false'", script) + self.assertIn(str(paths.cas(graph.node(restore).uid, restore).output), script) + if variant.kind in {"coverage", "ubsan"}: + self.assertIn("'/p:LLVMInstallDir=", script) + else: + self.assertNotIn("'/p:LLVMInstallDir=", script) + if variant.kind == "ubsan": + self.assertIn("'/p:LLVMRuntimeDir=", script) + else: + self.assertNotIn("'/p:LLVMRuntimeDir=", script) + artifact = next( + item + for item in artifacts + if (item.kind, item.architecture, item.name) + == (variant.kind, variant.architecture, project) + ) + self.assertEqual(artifact.producer, build) + self.assertEqual(artifact.path, paths.cas(build.uid, build.name).output / filename) + + def test_invalid_variants_manifests_and_runtime_contracts_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + runtime = self.llvm_runtime(root) + good = (InstrumentedVariant("coverage", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=good + ) + + for variant in ( + InstrumentedVariant("msan", "x64"), + InstrumentedVariant("asan", "arm64"), + InstrumentedVariant("ubsan", "x86", runtime, {"hash": "x"}), + ): + with self.subTest(variant=variant), self.assertRaisesRegex(ValueError, "variant"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=(variant,) + ) + with self.assertRaisesRegex(ValueError, "duplicate"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=good + good + ) + with self.assertRaisesRegex(ValueError, "at least one"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=() + ) + with self.assertRaisesRegex(ValueError, "jobs"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=good, jobs=True + ) + with self.assertRaisesRegex(ValueError, "runtime"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=(InstrumentedVariant("ubsan", "x64"),), + ) + with self.assertRaisesRegex(ValueError, "runtime"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=(InstrumentedVariant("coverage", "x64", runtime, {"x": "y"}),), + ) + + manifests = self.manifests(repository, discovery) + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests={}, + variants=good, + ) + with self.assertRaisesRegex(ValueError, "discovery is required"): + instrumented_build_slice( + repository, + toolchain, + discovery=None, + manifests=manifests, + variants=good, + ) + with self.assertRaisesRegex(ValueError, "jobs"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=good, + jobs=0, + ) + conflicting_pool = Graph( + discovery.nodes, + discovery.targets, + {"restore": 1, "slot": 3}, + ) + with self.assertRaisesRegex(ValueError, "conflicting slot"): + instrumented_build_slice( + repository, + toolchain, + discovery=conflicting_pool, + manifests=manifests, + variants=good, + jobs=2, + ) + + project = repository / "build/projects/tests.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "$(RepositoryRoot)src\\tests.cpp", "src\\tests.cpp" + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "unsupported project input"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=good, + ) + project.write_text( + project.read_text(encoding="utf-8").replace( + "src\\tests.cpp", "$(RepositoryRoot)src\\tests.cpp" + ), + encoding="utf-8", + ) + + empty_runtime = root / "empty-runtime" + empty_runtime.mkdir() + with self.assertRaisesRegex(FileNotFoundError, "UBSan runtime library"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=( + InstrumentedVariant( + "ubsan", "x64", empty_runtime, {"hash": "x"} + ), + ), + ) + + original = discovery.node("restore-vcpkg-x64") + conflicting = Node( + original.name, + "0" * 32, + original.pool, + original.command, + original.inputs, + ) + conflict_graph = Graph( + (conflicting,), (conflicting.name,), {"restore": 1, "slot": 2} + ) + with ( + mock.patch( + "graphs.instrumented.clang_dependency_discovery_slice", + side_effect=(discovery, conflict_graph), + ), + self.assertRaisesRegex(ValueError, "conflicting canonical node"), + ): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, + {"standalone": "a", "cxx": "b"}, + ), + ), + ) + + def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + runtime = self.llvm_runtime(root) + variants = ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant("asan", "x64"), + InstrumentedVariant("ubsan", "x64", runtime, {"hash": "before"}), + ) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, discovery) + before, _artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + ) + clang = toolchain.llvm_dir / "bin/clang-cl.exe" + clang.write_bytes(b"clang-v2") + clang_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + clang_changed, _artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=clang_discovery, + manifests=manifests, + variants=variants, + ) + clang.write_bytes(b"clang-v1") + scanner = toolchain.llvm_dir / "bin/clang-scan-deps.exe" + scanner.write_bytes(b"scan-v2") + scanner_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + scanner_changed, _artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=scanner_discovery, + manifests=manifests, + variants=variants, + ) + scanner.write_bytes(b"scan-v1") + changed_variants = ( + variants[0], + variants[1], + InstrumentedVariant("ubsan", "x64", runtime, {"hash": "after"}), + ) + runtime_changed, _artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=changed_variants, + ) + (repository / "src/renpy.h").write_text( + "#pragma once\n// changed\n", encoding="utf-8" + ) + source_changed, _artifacts = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + ) + changed_toolchain = replace( + toolchain, identity=toolchain.identity | {"llvm": "20.2"} + ) + changed_discovery = instrumented_dependency_discovery_slice( + repository, changed_toolchain, variants=variants + ) + toolchain_changed, _artifacts = instrumented_build_slice( + repository, + changed_toolchain, + discovery=changed_discovery, + manifests=manifests, + variants=variants, + ) + + for kind in ("coverage", "asan", "ubsan"): + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + name = f"build-{project}-x64-{kind}" + self.assertEqual( + kind == "ubsan", + before.node(name).uid != runtime_changed.node(name).uid, + name, + ) + self.assertEqual( + kind in {"coverage", "ubsan"}, + before.node(name).uid != clang_changed.node(name).uid, + name, + ) + self.assertEqual( + kind in {"coverage", "ubsan"}, + before.node(name).uid != scanner_changed.node(name).uid, + name, + ) + self.assertEqual( + project == "renpy", + before.node(name).uid != source_changed.node(name).uid, + name, + ) + self.assertNotEqual( + before.node(name).uid, toolchain_changed.node(name).uid, name + ) + + def test_clang_discovery_skips_unsupported_leak_arch_and_signs_prior_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + source = repository / "src/leak-probe.cpp" + name = dependency_node_name( + repository, "x64", "leak-probe", source, "coverage" + ) + manifest = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str((repository / "src/tests.h").resolve())], + } + } + ).encode() + with mock.patch("graphs.analysis._manifest_index", return_value={name: manifest}): + discovery = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("leak-probe",), + configuration="Coverage", + name_qualifier="coverage", + architectures=("x86", "x64"), + ) + with mock.patch("graphs.analysis._manifest_index", return_value={}): + without_prior = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("leak-probe",), + configuration="Coverage", + name_qualifier="coverage", + architectures=("x86", "x64"), + ) + asan = (InstrumentedVariant("asan", "x64"),) + asan_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=asan + ) + instrumented_build_slice( + repository, + toolchain, + discovery=asan_discovery, + manifests=self.manifests(repository, asan_discovery), + variants=asan, + ) + + self.assertEqual(discovery.targets, (name,)) + capture = discovery.node(discovery.node(name).inputs[0]) + previous_capture = without_prior.node(without_prior.node(name).inputs[0]) + self.assertNotEqual(capture.uid, previous_capture.uid) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_leak_graph.py b/tools/build/tests/test_leak_graph.py new file mode 100644 index 0000000..759a2fb --- /dev/null +++ b/tools/build/tests/test_leak_graph.py @@ -0,0 +1,611 @@ +from __future__ import annotations + +import hashlib +from dataclasses import replace +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.graph import Command, Graph, Node # noqa: E402 +import core.leak as leak # noqa: E402 +from core.leak import LeakError, main as leak_main # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.audit import BinaryArtifact # noqa: E402 +from graphs.leak import LEAK_MODES, LEAK_SCENARIOS, leak_graph # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("build.exe",)), + inputs, + ) + + +class LeakGraphTests(unittest.TestCase): + def fixture( + self, root: Path + ) -> tuple[Path, Graph, tuple[BinaryArtifact, ...], Path]: + repository = root / "repo" + repository.mkdir() + restore = node("restore-release") + builds = tuple( + node(f"build-{name}-x64-release", inputs=(restore.name,)) + for name in ("leak-probe", "renpy", "rpgmaker", "zanzarah") + ) + audit_gates = tuple( + node(f"audit-{kind}-x64-{module}", inputs=(producer.name,)) + for module, producer in zip( + ("leak-probe", "renpy", "rpgmaker", "zanzarah"), builds, strict=True + ) + for kind in ("pe", "binskim") + ) + upstream = Graph((restore, *builds, *audit_gates), tuple(item.name for item in builds), {"build": 4}) + paths = BuildPaths(repository) + artifacts = tuple( + BinaryArtifact( + "x64", + name, + producer, + paths.cas(producer.uid, producer.name).output + / ("leak-probe.exe" if name == "leak-probe" else f"{name}.so"), + ) + for name, producer in zip( + ("leak-probe", "renpy", "rpgmaker", "zanzarah"), builds, strict=True + ) + ) + tools = root / "tools" + tools.mkdir() + umdh = tools / "umdh.exe" + umdh.touch() + return repository, upstream, artifacts, umdh + + def build_graph(self, root: Path, **options: object) -> Graph: + repository, upstream, artifacts, umdh = self.fixture(root) + return leak_graph( + repository, + upstream, + artifacts, + umdh=umdh, + umdh_identity={"version": "10.0"}, + run_nonce="run-42", + **options, + ) + + def test_default_graph_has_one_shared_setup_and_fourteen_independent_branches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + graph = self.build_graph(Path(temporary), jobs=7, session_jobs=3, diff_jobs=5) + + leak_nodes = tuple(item for item in graph.nodes if item.name.startswith("leak-")) + self.assertEqual(85, len(leak_nodes)) + self.assertEqual( + graph.pools, + {"build": 4, "leak": 7, "leak-session": 3, "leak-diff": 5}, + ) + self.assertEqual( + graph.targets, + tuple( + f"leak-judge-{mode}-{scenario}" + for mode in LEAK_MODES + for scenario in LEAK_SCENARIOS + ), + ) + + setup = graph.node("leak-setup-x64-release") + self.assertEqual( + setup.inputs, + ("build-leak-probe-x64-release",) + + tuple( + dependency + for module in ("renpy", "rpgmaker", "zanzarah") + for dependency in ( + f"build-{module}-x64-release", + f"audit-pe-x64-{module}", + f"audit-binskim-x64-{module}", + ) + ), + ) + self.assertEqual(setup.command.argv[1:4], ("-m", "core.leak", "setup")) + + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"{mode}-{scenario}" + preflight = graph.node(f"leak-preflight-{stem}") + capture = graph.node(f"leak-capture-{stem}") + adjacent = tuple( + graph.node(f"leak-diff-{stem}-window-{index}") for index in (1, 2) + ) + overall = graph.node(f"leak-diff-{stem}-overall") + judge = graph.node(f"leak-judge-{stem}") + + self.assertEqual(preflight.inputs, (setup.name,)) + self.assertEqual(capture.inputs, (preflight.name,)) + self.assertTrue(all(item.inputs == (capture.name,) for item in (*adjacent, overall))) + self.assertEqual(judge.inputs, tuple(item.name for item in (*adjacent, overall))) + self.assertEqual( + (preflight.pool, capture.pool, adjacent[0].pool, judge.pool), + ("leak", "leak-session", "leak-diff", "leak"), + ) + self.assertEqual(preflight.command.argv[-2:], (mode, scenario)) + self.assertEqual(capture.command.argv[6:8], (mode, scenario)) + self.assertEqual((overall.command.argv[3], overall.command.argv[6]), ("diff", "overall")) + self.assertEqual(judge.command.argv[9], "0") + + def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build_graph( + root, warmup=2, iterations=3, windows=4, tolerance_bytes=17 + ) + + self.assertEqual(99, len(tuple(item for item in graph.nodes if item.name.startswith("leak-")))) + capture = graph.node("leak-capture-operations-small-success") + self.assertEqual(capture.command.argv[-3:], ("2", "3", "4")) + judge = graph.node("leak-judge-operations-small-success") + self.assertEqual( + judge.inputs, + ( + "leak-diff-operations-small-success-window-1", + "leak-diff-operations-small-success-window-2", + "leak-diff-operations-small-success-window-3", + "leak-diff-operations-small-success-overall", + ), + ) + self.assertEqual(judge.command.argv[3:10], ("judge", "operations", "small-success", "2", "3", "4", "17")) + + def test_run_and_tool_identities_invalidate_only_the_measurement_partition(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, umdh = self.fixture(root) + + def graph( + *, + nonce: str = "run-a", + umdh_version: str = "10.0", + tolerance: int = 4096, + ) -> Graph: + return leak_graph( + repository, + upstream, + artifacts, + umdh=umdh, + umdh_identity={"version": umdh_version}, + run_nonce=nonce, + tolerance_bytes=tolerance, + ) + + before = graph() + rerun = graph(nonce="run-b") + new_umdh = graph(umdh_version="10.1") + new_tolerance = graph(tolerance=8192) + + for current in before.nodes: + if not current.name.startswith("leak-"): + continue + measured = current.name.startswith(("leak-capture-", "leak-diff-", "leak-judge-")) + judged = current.name.startswith("leak-judge-") + self.assertEqual(measured, current.uid != rerun.node(current.name).uid, current.name) + self.assertEqual(measured, current.uid != new_umdh.node(current.name).uid, current.name) + self.assertEqual(judged, current.uid != new_tolerance.node(current.name).uid, current.name) + + def test_rejects_invalid_tools_artifacts_pools_and_measurement_options(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, umdh = self.fixture(root) + + def invoke( + selected: tuple[BinaryArtifact, ...] = artifacts, + *, + source: Graph = upstream, + umdh_path: Path = umdh, + nonce: object = "run-42", + warmup: object = 8, + iterations: object = 100, + windows: object = 3, + tolerance: object = 4096, + jobs: object = 4, + session_jobs: object = 2, + diff_jobs: object = 4, + ) -> Graph: + return leak_graph( + repository, + source, + selected, + umdh=umdh_path, + umdh_identity={"version": "10.0"}, + run_nonce=nonce, # type: ignore[arg-type] + warmup=warmup, # type: ignore[arg-type] + iterations=iterations, # type: ignore[arg-type] + windows=windows, # type: ignore[arg-type] + tolerance_bytes=tolerance, # type: ignore[arg-type] + jobs=jobs, # type: ignore[arg-type] + session_jobs=session_jobs, # type: ignore[arg-type] + diff_jobs=diff_jobs, # type: ignore[arg-type] + ) + + for capacities in ((True, 2, 4), (4, "two", 4), (4, 2, 0)): + with self.subTest(capacities=capacities), self.assertRaisesRegex( + ValueError, "capacities" + ): + invoke(jobs=capacities[0], session_jobs=capacities[1], diff_jobs=capacities[2]) + for counts in ((True, 100, 3), (8, "many", 3), (8, 100, 0)): + with self.subTest(counts=counts), self.assertRaisesRegex(ValueError, "counts"): + invoke(warmup=counts[0], iterations=counts[1], windows=counts[2]) + with self.assertRaisesRegex(ValueError, "at least three"): + invoke(windows=2) + for tolerance in (True, "zero", -1): + with self.subTest(tolerance=tolerance), self.assertRaisesRegex(ValueError, "tolerance"): + invoke(tolerance=tolerance) + for nonce in (7, "", "bad\0nonce"): + with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "nonce"): + invoke(nonce=nonce) + + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(umdh_path=umdh.parent) + with self.assertRaisesRegex(ValueError, "missing"): + invoke(artifacts[:-1]) + with self.assertRaisesRegex(ValueError, "duplicate"): + invoke((*artifacts, artifacts[0])) + for invalid in ( + replace(artifacts[0], architecture="x86"), + replace(artifacts[0], module="unknown"), + ): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "invalid"): + invoke((invalid, *artifacts[1:])) + + missing_gate = Graph( + tuple(node for node in upstream.nodes if node.name != "audit-binskim-x64-renpy"), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "audit gates"): + invoke(source=missing_gate) + + impostor = Node( + artifacts[0].producer.name, + artifacts[0].producer.uid, + "build", + Command(("other-build.exe",)), + artifacts[0].producer.inputs, + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((replace(artifacts[0], producer=impostor), *artifacts[1:])) + unknown = node("unknown-producer") + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((replace(artifacts[0], producer=unknown), *artifacts[1:])) + + paths = BuildPaths(repository) + producer_output = paths.cas(artifacts[0].producer.uid, artifacts[0].producer.name).output + bad_paths = ( + producer_output / "wrong-name.exe", + paths.cas(artifacts[1].producer.uid, artifacts[1].producer.name).output + / "leak-probe.exe", + ) + for bad_path in bad_paths: + with self.subTest(bad_path=bad_path), self.assertRaisesRegex( + ValueError, "producer CAS" + ): + invoke((replace(artifacts[0], path=bad_path), *artifacts[1:])) + + matching = Graph( + upstream.nodes, + upstream.targets, + {"build": 4, "leak": 4, "leak-session": 2, "leak-diff": 4}, + ) + self.assertEqual(4, invoke(source=matching).pools["leak"]) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "leak": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflicting) + + +class FakeProbe: + def __init__(self, lines: list[str], *, result: int = 0, timeout: bool = False) -> None: + class Stream(io.StringIO): + def close(stream) -> None: + stream.was_closed = True + + self.stdout = Stream("\n".join(lines) + "\n") + self.stdin = Stream() + self.pid = 77 + self.result = result + self.timeout = timeout + self.returncode: int | None = None + self.killed = False + self.descendant = mock.Mock() + + def wait(self, timeout: int = 0) -> int: + if self.timeout: + raise leak.psutil.TimeoutExpired(timeout, self.pid) + self.returncode = self.result + return self.result + + def poll(self) -> int | None: + return self.returncode + + def children(self, *, recursive: bool) -> list[object]: + self.recursive = recursive + return [self.descendant] + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + + +class LeakWorkerTests(unittest.TestCase): + def output(self, root: Path): + return mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}, clear=False) + + def test_setup_stages_exact_binaries_symbols_and_hash_evidence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "out" + output.mkdir() + sources = [] + for index, name in enumerate(leak.BINARIES): + directory = root / str(index) + directory.mkdir() + source = directory / name + source.write_bytes(name.encode()) + (directory / f"{index}.pdb").write_bytes(bytes([index])) + sources.append(source) + with self.output(output): + self.assertEqual(0, leak_main(("setup", *(str(path) for path in sources)))) + evidence = json.loads((output / "release-binaries.json").read_text(encoding="utf-8")) + + self.assertEqual(list(leak.BINARIES), [item["name"] for item in evidence["binaries"]]) + self.assertEqual("MT_StaticRelease", evidence["runtimeLibrary"]) + self.assertTrue(all((output / name).is_file() for name in leak.BINARIES)) + self.assertTrue(all((output / f"{index}.pdb").is_file() for index in range(4))) + + with self.output(output), self.assertRaisesRegex(LeakError, "not found"): + leak_main(("setup", *(str(path) for path in (*sources[:-1], root / "missing")))) + + def test_preflight_uses_communicate_safe_capture_and_checks_markers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + ready = "OBSERVER_LEAK_PROBE|READY|pid=12|mode=operations|configuration=Release|scenarios=malformed" + complete = subprocess.CompletedProcess([], 0, ready + "\nOBSERVER_LEAK_PROBE|DONE|pid=12\n") + with self.output(root), mock.patch("core.leak.subprocess.run", return_value=complete) as invoked: + leak_main(("preflight", str(root), "operations", "malformed")) + self.assertIs(invoked.call_args.kwargs["stderr"], subprocess.STDOUT) + self.assertIn("--automatic", invoked.call_args.args[0]) + + failures = ( + subprocess.CompletedProcess([], 2, "broken"), + subprocess.CompletedProcess([], 0, ready), + subprocess.CompletedProcess([], 0, ready.replace("malformed", "read-failure") + "\nOBSERVER_LEAK_PROBE|DONE|pid=12"), + ) + for result in failures: + with self.subTest(result=result), self.output(root), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaises(LeakError): + leak_main(("preflight", str(root), "operations", "malformed")) + + def test_capture_streams_stdout_redirects_stderr_to_file_and_uses_psutil(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + umdh = root / "umdh.exe" + umdh.touch() + lines = [ + "probe startup noise", + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|completed_operations=1" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|completed_operations=4", + ] + process = FakeProbe(lines) + + def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace 1\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + with self.output(root), mock.patch("core.leak.psutil.Popen", return_value=process) as popen, mock.patch( + "core.leak.subprocess.run", side_effect=snapshot + ): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + + self.assertIsNot(popen.call_args.kwargs["stderr"], subprocess.PIPE) + self.assertEqual("continue|baseline\ncontinue|window-1\ncontinue|window-2\ncontinue|window-3\n", process.stdin.getvalue()) + self.assertTrue(process.stdin.was_closed) + self.assertTrue(process.stdout.was_closed) + self.assertEqual(77, json.loads((root / "capture.json").read_text())["processId"]) + + def test_capture_kills_the_probe_tree_on_protocol_timeout_or_exit_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + umdh = root / "umdh.exe" + umdh.touch() + cases = ( + FakeProbe(["OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed"]), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + "OBSERVER_LEAK_PROBE|SNAPSHOT|baseline|pid=12|", + ]), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ], timeout=True), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ], result=5), + ) + + def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + for index, process in enumerate(cases): + output = root / f"output-{index}" + output.mkdir() + with self.subTest(process=process), self.output(output), mock.patch( + "core.leak.psutil.Popen", return_value=process + ), mock.patch("core.leak.psutil.wait_procs"), mock.patch( + "core.leak.subprocess.run", side_effect=snapshot + ), self.assertRaises(LeakError): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + if process.timeout or process.result == 0: + self.assertTrue(process.killed) + + def test_diff_and_judge_preserve_growth_evidence_and_reject_leaks(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + report_text = "\n".join(("+ 500 (x) 1 allocs BackTrace ABC", "Total increase == 500")) + + def compare(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[-1].removeprefix("-f:")).write_text(report_text, encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + with self.output(root), mock.patch("core.leak.subprocess.run", side_effect=compare): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + first = root / "diff.json" + self.assertEqual(500, json.loads(first.read_text())["positiveStacks"]["ABC"]) + + paths = [] + for label, total in (("window-1", 500), ("window-2", 500), ("overall", 1001)): + path = root / f"{label}.json" + path.write_text(json.dumps({"label": label, "totalIncrease": total, "positiveStacks": {"ABC": 500}})) + paths.extend((label, str(path))) + with self.output(root), self.assertRaisesRegex(LeakError, "sustained"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) + self.assertFalse(json.loads((root / "summary.json").read_text())["passed"]) + + for path in (root / "window-1.json", root / "window-2.json", root / "overall.json"): + document = json.loads(path.read_text()) + document["totalIncrease"] = 0 + document["positiveStacks"] = {} + path.write_text(json.dumps(document)) + with self.output(root): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) + self.assertTrue(json.loads((root / "summary.json").read_text())["passed"]) + + def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(LeakError, "OBSERVER_OUT_DIR"): + leak._output() + with self.assertRaisesRegex(LeakError, "setup expects"): + leak_main(("setup",)) + with self.assertRaisesRegex(LeakError, "not found"): + leak_main(("preflight", "missing", "operations", "malformed")) + for mode, scenario in (("bad", "malformed"), ("operations", "bad")): + with self.subTest(selection=(mode, scenario)), self.assertRaisesRegex(LeakError, "selection"): + leak._selection(mode, scenario) + for value, message in (("many", "integer"), ("0", "at least")): + with self.subTest(value=value), self.assertRaisesRegex(LeakError, message): + leak._count(value, "rounds") + with self.assertRaisesRegex(LeakError, "requested process"): + leak._ready( + "OBSERVER_LEAK_PROBE|READY|pid=2|mode=operations|configuration=Release|scenarios=malformed", + "operations", "malformed", 1, + ) + with self.assertRaisesRegex(LeakError, "expected leak action"): + leak_main(()) + with self.assertRaisesRegex(LeakError, "expected leak action"): + leak_main(("unknown",)) + with mock.patch.object(sys, "argv", ["leak.py", "unknown"]), self.assertRaises(LeakError): + leak_main() + + process = FakeProbe([]) + process.descendant.kill.side_effect = leak.psutil.NoSuchProcess(88) + with mock.patch("core.leak.psutil.wait_procs") as waited: + leak._kill_tree(process) # type: ignore[arg-type] + waited.assert_called_once() + self.assertTrue(process.killed) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + destination = root / "snapshot.txt" + bad_results = ( + (subprocess.CompletedProcess([], 2, "bad"), True, ""), + (subprocess.CompletedProcess([], 1, "bad"), True, "wrong"), + (subprocess.CompletedProcess([], 1, "bad"), False, "BackTrace"), + (subprocess.CompletedProcess([], 0, ""), False, "database is full BackTrace"), + (subprocess.CompletedProcess([], 0, ""), False, "empty"), + ) + for result, baseline, content in bad_results: + if content: + destination.write_text(content, encoding="utf-8") + elif destination.exists(): + destination.unlink() + with self.subTest(snapshot=(result.returncode, baseline, content)), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaises(LeakError): + leak._snapshot(Path("umdh"), 1, destination, baseline) + + report = root / "report.txt" + + def diff_result(returncode: int, content: str | None): + def run(_argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if content is not None: + report.write_text(content, encoding="utf-8") + elif report.exists(): + report.unlink() + return subprocess.CompletedProcess([], returncode, "bad") + + return run + + with self.output(root): + for returncode, content, message in ( + (2, None, "failed"), + (0, None, "failed"), + (0, "no totals", "no total"), + ): + with self.subTest(diff=(returncode, content)), mock.patch( + "core.leak.subprocess.run", side_effect=diff_result(returncode, content) + ), self.assertRaisesRegex(LeakError, message): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + with mock.patch( + "core.leak.subprocess.run", + side_effect=diff_result(0, "Total decrease == 7"), + ): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + self.assertEqual(-7, json.loads((root / "diff.json").read_text())["totalIncrease"]) + + def test_judge_rejects_shape_and_label_mismatches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + documents = [] + for label in ("window-1", "window-2", "overall"): + path = root / f"{label}.json" + path.write_text(json.dumps({"label": label, "totalIncrease": 0, "positiveStacks": {}})) + documents.extend((label, str(path))) + with self.output(root): + for arguments in ( + ("judge", "operations"), + ("judge", "operations", "malformed", "1", "2", "3", "0", "window-1", "x", "extra"), + ): + with self.subTest(arguments=arguments), self.assertRaisesRegex(LeakError, "judge expects"): + leak_main(arguments) + with self.assertRaisesRegex(LeakError, "labels"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", "wrong", *documents[1:])) + first = Path(documents[1]) + value = json.loads(first.read_text()) + value["label"] = "wrong" + first.write_text(json.dumps(value)) + with self.assertRaisesRegex(LeakError, "labels"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", *documents)) + + with ( + mock.patch.object(sys, "argv", ["leak.py", "setup"]), + self.assertWarnsRegex(RuntimeWarning, "core.leak"), + self.assertRaises(RuntimeError), + ): + runpy.run_module("core.leak", run_name="__main__") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_main.py b/tools/build/tests/test_main.py new file mode 100644 index 0000000..7615ee6 --- /dev/null +++ b/tools/build/tests/test_main.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +import runpy +import re +from types import SimpleNamespace +import sys +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import main # noqa: E402 + + +class FakeDriver: + instances: list[FakeDriver] = [] + + def __init__(self, *args: object, **kwargs: object) -> None: + self.constructor = (args, kwargs) + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + FakeDriver.instances.append(self) + + def __getattr__(self, name: str): + async def invoke(*args: object, **kwargs: object) -> tuple[Path, ...]: + self.calls.append((name, args, kwargs)) + return (Path("out") / name,) + return invoke + + +class MainTests(unittest.TestCase): + def setUp(self) -> None: + FakeDriver.instances.clear() + + def invoke(self, argv: list[str], *, deferred: tuple[object, ...] = ()): + toolchain = object() + source_tools = object() + dumpbin, binskim, umdh = object(), object(), object() + stdout = StringIO() + patches = ( + mock.patch.object(main, "BuildDriver", FakeDriver), + mock.patch.object(main, "_run_id", return_value="run-id"), + mock.patch.object(main, "discover_msvc_toolchain", return_value=toolchain), + mock.patch.object(main, "discover_source_tools", return_value=source_tools), + mock.patch.object(main, "resolve_dumpbin", return_value=dumpbin), + mock.patch.object(main, "resolve_binskim", return_value=binskim), + mock.patch.object(main, "resolve_umdh", return_value=umdh), + mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=deferred)), + ) + with ( + patches[0] as driver, patches[1], patches[2] as discover, + patches[3] as source, patches[4] as find_dumpbin, + patches[5] as find_binskim, patches[6] as find_umdh, + patches[7] as route, redirect_stdout(stdout), + ): + result = main.main(argv) + return SimpleNamespace( + result=result, stdout=stdout.getvalue(), driver=driver, discover=discover, + source=source, dumpbin=find_dumpbin, binskim=find_binskim, umdh=find_umdh, + route=route, source_tools=source_tools, tools=(dumpbin, binskim, umdh), + ) + + def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> None: + repository = Path("selected-repo") + dumpbin, binskim, umdh = object(), object(), object() + cases = ( + ("build", ["-Arch", " x64, X64,x86 ", "-Config", "debug, RELEASE"], + (("x64", "x86"), ("Debug", "Release")), {}), + ("test", ["-Config", "Release", "-Corpus", "golden", "-TestShards", "7"], + (("x64",), ("Release",)), {"test_shards": 7, "corpus": Path("golden"), "run_nonce": "run-id"}), + ("compiler_analysis", [], (("x64",),), {}), + ("test_coverage", ["-Corpus", "golden", "-CoverageThreshold", "100"], + (("x64",),), {"test_shards": 4, "corpus": Path("golden"), "run_nonce": "run-id"}), + ("test_asan", ["-Arch", "x86,x64"], (("x86", "x64"),), {"test_shards": 4}), + ("test_ubsan", [], (("x64",),), {"test_shards": 4}), + ("fuzz", ["-FuzzSeconds", "91", "-FuzzTarget", "RenPy"], (), + {"run_nonce": "run-id", "seconds": 91, "targets": ("renpy",)}), + ("verify", ["-Arch", "all", "-Corpus", "golden", "-FuzzSeconds", "17", + "-LeakWarmup", "2", "-LeakIterations", "5", "-LeakWindows", "4", + "-LeakToleranceBytes", "9"], (("x86", "x64", "arm64"),), + {"corpus": Path("golden"), "run_nonce": "run-id", "fuzz_seconds": 17, + "test_shards": 4, "warmup": 2, "iterations": 5, "windows": 4, + "tolerance_bytes": 9}), + ) + command_names = { + "compiler_analysis": "compiler-analysis", "test_coverage": "test-coverage", + "test_asan": "test-asan", "test_ubsan": "test-ubsan", + } + for method, options, positional, keywords in cases: + with self.subTest(command=method): + result = self.invoke([command_names.get(method, method), "-Repository", str(repository), *options]) + instance = FakeDriver.instances[-1] + self.assertEqual(instance.constructor, ((repository, "run-id", mock.ANY), {"jobs": None})) + self.assertEqual(instance.calls, [(method, positional, keywords)]) + self.assertEqual(result.result, 0) + self.assertEqual(result.stdout.strip(), str(Path("out") / method)) + + restore = self.invoke([ + "restore", "-Repository", str(repository), "-Arch", "arm64,ALL", + "-RestoreFlavor", "ALL", + ]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("restore", (("x86", "x64", "arm64"),), {"flavors": ("",)}), + ("restore", (("x86", "x64"),), {"flavors": ("asan",)}), + ]) + self.invoke(["restore", "-Repository", str(repository)]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("restore", (("x64",),), {"flavors": ("",)}) + ]) + no_op = self.invoke([ + "restore", "-Repository", str(repository), "-Arch", "arm64", + "-RestoreFlavor", "asan", + ]) + self.assertEqual(FakeDriver.instances[-1].calls, []) + self.assertEqual(no_op.stdout, "") + + source = self.invoke(["source-checks", "-Repository", str(repository)]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("source_checks", (("x64",), source.source_tools), {}) + ]) + source.source.assert_called_once() + + for command, method in (("audit-binaries", "audit"), ("package", "package")): + with self.subTest(command=command): + result = self.invoke([command, "-Repository", str(repository), "-Jobs", "3"]) + dumpbin, binskim, _umdh = result.tools + self.assertEqual(FakeDriver.instances[-1].calls, [ + (method, (("x64",),), {"dumpbin": dumpbin, "binskim": binskim}) + ]) + + leak = self.invoke([ + "test-leaks", "-Repository", str(repository), "-LeakWarmup", "2", + "-LeakIterations", "6", "-LeakWindows", "5", "-LeakToleranceBytes", "10", + ]) + dumpbin, binskim, umdh = leak.tools + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("test_leaks", (), { + "run_nonce": "run-id", "dumpbin": dumpbin, "binskim": binskim, "umdh": umdh, + "warmup": 2, "iterations": 6, "windows": 5, "tolerance_bytes": 10, + }) + ]) + + def test_doctor_and_clean_bypass_toolchain_and_driver(self) -> None: + with ( + mock.patch.object(main, "doctor_main", return_value=1) as doctor, + mock.patch.object(main, "discover_msvc_toolchain") as discover, + ): + self.assertEqual(main.main(["doctor"]), 1) + doctor.assert_called_once_with(()) + discover.assert_not_called() + + with mock.patch.object(main, "run_clean", return_value=0) as clean: + self.assertEqual(main.main([ + "clean", "-Repository", "repo", "-CleanMode", "stale-work" + ]), 0) + clean.assert_called_once_with(Path("repo"), "stale-work") + self.assertEqual(FakeDriver.instances, []) + + def test_clean_adapter_and_run_identifier_are_exact(self) -> None: + with mock.patch("core.clean.main", return_value=0) as clean: + self.assertEqual(main.run_clean(Path("repo"), "all"), 0) + clean.assert_called_once_with((str(Path("repo")), "--mode", "all")) + self.assertRegex(main._run_id(), re.compile(r"^\d{8}T\d{6}-\d+$")) + + def test_verify_prints_explicit_host_deferrals_before_outputs(self) -> None: + deferred = SimpleNamespace(gate="tests", architecture="arm64", reason="not runnable") + result = self.invoke(["verify", "-Arch", "arm64"], deferred=(deferred,)) + self.assertEqual( + result.stdout.splitlines(), + ["[DEFERRED] tests arm64: not runnable", str(Path("out") / "verify")], + ) + + async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]: + raise RuntimeError("verify failed") + + stdout = StringIO() + with ( + mock.patch.dict(main._INVOKE, {"verify": fail}), + mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=(deferred,))), + mock.patch.object(main, "discover_msvc_toolchain", return_value=object()), + mock.patch.object(main, "BuildDriver", FakeDriver), + mock.patch.object(main, "_run_id", return_value="run-id"), + redirect_stdout(stdout), self.assertRaisesRegex(RuntimeError, "verify failed"), + ): + main.main(["verify", "-Arch", "arm64"]) + self.assertEqual(stdout.getvalue(), "") + + def test_help_and_deprecated_skip_restore_contract(self) -> None: + for argv in ([], ["help"]): + with self.subTest(argv=argv), redirect_stdout(StringIO()) as stdout: + self.assertEqual(main.main(argv), 0) + self.assertIn("audit-binaries", stdout.getvalue()) + + result = self.invoke(["build", "-SkipDependencyRestore"]) + self.assertEqual(FakeDriver.instances[-1].calls[0][0], "build") + with redirect_stderr(StringIO()), self.assertRaises(SystemExit) as raised: + main.main(["restore", "-SkipDependencyRestore"]) + self.assertEqual(raised.exception.code, 2) + + def test_invalid_legacy_options_fail_before_discovery(self) -> None: + cases = ( + ["build", "-Arch", "mips"], + ["build", "-Arch", ""], ["build", "-Config", "Profile"], + ["restore", "-RestoreFlavor", "ubsan"], ["fuzz", "-FuzzSeconds", "0"], + ["fuzz", "-FuzzSeconds", "86401"], ["fuzz", "-FuzzTarget", "bad"], + ["fuzz", "-Arch", "x86"], ["test-leaks", "-Arch", "x86"], + ["test-leaks", "-LeakWarmup", "0"], ["test-leaks", "-LeakIterations", "x"], + ["test-leaks", "-LeakWindows", "2"], ["test-leaks", "-LeakWindows", "11"], + ["test-leaks", "-LeakToleranceBytes", "-1"], + ["test-coverage", "-CoverageThreshold", "99"], + ["test-coverage", "-CoverageThreshold", "100.0"], + ["test", "-TestShards", "0"], ["build", "-Jobs", "0"], + ) + for argv in cases: + with ( + self.subTest(argv=argv), + mock.patch.object(main, "discover_msvc_toolchain") as discover, + redirect_stderr(StringIO()), self.assertRaises(SystemExit) as raised, + ): + main.main(argv) + self.assertEqual(raised.exception.code, 2) + discover.assert_not_called() + + def test_script_entry_point_uses_the_same_cli(self) -> None: + with ( + mock.patch.object(sys, "argv", [str(BUILD_ROOT / "main.py"), "doctor"]), + mock.patch("core.doctor.main", return_value=0) as doctor, + self.assertRaises(SystemExit) as raised, + ): + runpy.run_path(str(BUILD_ROOT / "main.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + doctor.assert_called_once_with(()) + + def test_root_powershell_entry_point_uses_frozen_project_environment(self) -> None: + script = (BUILD_ROOT.parents[1] / "build.ps1").read_text(encoding="utf-8") + + self.assertIn("uv run --project $buildProject --frozen --no-sync", script) + self.assertIn("tools\\build", script) + self.assertIn("exit $LASTEXITCODE", script) + self.assertNotIn("build\\build.ps1", script) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_native_graph.py b/tools/build/tests/test_native_graph.py new file mode 100644 index 0000000..5b32b74 --- /dev/null +++ b/tools/build/tests/test_native_graph.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs.native import native_dependency_discovery_slice, native_graph # noqa: E402 + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class NativeGraphTests(unittest.TestCase): + def test_parallel_msvc_builds_do_not_depend_on_shared_compiler_pdb_server( + self, + ) -> None: + root = ET.parse( + BUILD_ROOT.parents[1] / "build/ObserverProject.props" + ).getroot() + debug_information = root.find( + ".//{http://schemas.microsoft.com/developer/msbuild/2003}DebugInformationFormat" + ) + + self.assertIsNotNone(debug_information) + self.assertEqual(debug_information.text, "OldStyle") + + def repository(self, root: Path) -> Path: + project = """ + + + + + + {definition} + +""" + files = { + "src/renpy.cpp": '#include "renpy.h"\n#include \n', + "src/renpy.h": "#pragma once\n", + "src/rpgmaker.cpp": '#include "rpgmaker.h"\n', + "src/rpgmaker.h": "#pragma once\n", + "src/zanzarah.cpp": '#include "zanzarah.h"\n', + "src/zanzarah.h": "#pragma once\n", + "src/tests.cpp": ( + '#include "tests.h"\n#include \n' + ), + "src/tests.h": "#pragma once\n", + "src/leak-probe.cpp": '#include "leak-probe.h"\n#include \n', + "src/leak-probe.h": "#pragma once\n", + "src/renpy.def": "EXPORTS\n OpenStorage\n", + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + } + for architecture in ("x64", "arm64"): + files[f"build/vcpkg/triplets/observer-{architecture}-windows-static.cmake"] = ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ) + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"): + definition = ( + "" + "$(RepositoryRoot)src\\renpy.def" + "" + if name == "renpy" + else "" + ) + files[f"build/projects/{name}.vcxproj"] = project.format( + name=name, definition=definition + ) + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + tools.mkdir() + vcpkg_root = tools / "vcpkg" + vcpkg_root.mkdir() + (vcpkg_root / "vcpkg.exe").touch() + msbuild = tools / "MSBuild.exe" + pwsh = tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + return FakeToolchain( + msbuild=msbuild, + pwsh=pwsh, + vcpkg_root=vcpkg_root, + environment=(("PATH", str(tools)),), + identity={"msbuild": "17.14", "msvc": "14.44"}, + ) + + def staged_graphs(self, repository: Path, toolchain: FakeToolchain, **options): + discovery = native_dependency_discovery_slice( + repository, + toolchain, + jobs=options.get("jobs", 2), + architectures=options.get("architectures", ("x64",)), + configurations=options.get("configurations", ("Debug",)), + include_leak_probe=options.get("include_leak_probe", False), + ) + manifests = {} + for target in discovery.targets: + project = next( + name + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") + if target.endswith(f"-{name}") + ) + source = repository / f"src/{project}.cpp" + manifests[target] = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str((repository / f"src/{project}.h").resolve())], + } + } + ).encode() + return discovery, native_graph( + repository, toolchain, discovery=discovery, manifests=manifests, **options + ) + + def test_projects_restore_and_test_shards_form_a_fine_grained_dag(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, + self.toolchain(root), + architectures=("x64", "arm64"), + configurations=("Debug", "Release"), + runnable_architectures=("x64",), + test_shards=2, + jobs=8, + ) + + names = tuple(node.name for node in graph.nodes) + self.assertEqual(names.count("restore-vcpkg-x64"), 1) + self.assertEqual(names.count("restore-vcpkg-arm64"), 1) + self.assertIn("build-renpy-x64-debug", names) + self.assertIn("build-tests-arm64-release", names) + self.assertFalse(any("leak-probe" in name for name in names)) + self.assertEqual( + tuple(name for name in names if name.startswith("test-shard-")), + ( + "test-shard-x64-debug-0", + "test-shard-x64-debug-1", + "test-shard-x64-release-0", + "test-shard-x64-release-1", + ), + ) + self.assertEqual(graph.pools, {"restore": 1, "slot": 8}) + + restore = graph.node("restore-vcpkg-x64") + builds = tuple( + graph.node(f"build-{project}-x64-debug") + for project in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + expected = tuple( + next(name for name in discovery.targets if f"-debug-{project}-" in name) + for project in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + self.assertEqual(tuple(node.inputs[0] for node in builds), expected) + self.assertTrue(all(node.pool == "slot" for node in builds)) + for node in builds: + script = node.command.stdin.decode("utf-8") + self.assertIn("'/m:1'", script) + self.assertIn("'/p:BuildProjectReferences=false'", script) + self.assertIn('"/p:OutDir=$outDir\\"', script) + self.assertIn('"/p:IntDir=$buildDir\\"', script) + for node in builds: + self.assertIn("'/p:VcpkgInstalledDir=", node.command.stdin.decode()) + shard = graph.node("test-shard-x64-debug-0") + self.assertEqual( + shard.inputs, + tuple(node.name for node in builds), + ) + shard_script = shard.command.stdin.decode("utf-8") + for artifact in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"): + self.assertIn(artifact, shard_script) + self.assertIn("'--shard-count'", shard_script) + self.assertIn("'2'", shard_script) + self.assertIn("'--shard-index'", shard_script) + self.assertIn("'0'", shard_script) + self.assertIn("'JUnit::out=", shard_script) + + def test_leak_probe_is_an_explicit_release_only_request(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, + self.toolchain(root), + configurations=("Debug", "Release"), + runnable_architectures=(), + include_leak_probe=True, + ) + + names = tuple(node.name for node in graph.nodes) + self.assertIn("build-leak-probe-x64-release", names) + self.assertNotIn("build-leak-probe-x64-debug", names) + leak_probe = graph.node("build-leak-probe-x64-release") + self.assertEqual(len(leak_probe.inputs), 1) + self.assertIn("-release-leak-probe-", leak_probe.inputs[0]) + self.assertTrue(any("-release-leak-probe-" in name for name in discovery.targets)) + + def test_external_corpus_is_test_only_and_nonce_invalidated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + corpus = root / "corpus" + other_corpus = root / "other-corpus" + corpus.mkdir() + other_corpus.mkdir() + options = dict( + configurations=("Debug",), runnable_architectures=("x64",), test_shards=1 + ) + _discovery, baseline = self.staged_graphs(repository, toolchain, **options) + _discovery, no_corpus = self.staged_graphs( + repository, toolchain, corpus=None, run_nonce="ignored", **options + ) + _discovery, first = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-one", **options + ) + (corpus / "large-external-file.bin").write_bytes(b"not signed") + _discovery, content_changed = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-one", **options + ) + _discovery, rerun = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-two", **options + ) + _discovery, moved = self.staged_graphs( + repository, toolchain, corpus=other_corpus, run_nonce="run-one", **options + ) + _discovery, build_only = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="build-only", + configurations=("Release",), runnable_architectures=(), + ) + + shard_name = "test-shard-x64-debug-0" + corpus_name = "corpus-shard-x64-debug-0" + self.assertEqual(baseline.node(shard_name).uid, no_corpus.node(shard_name).uid) + self.assertEqual(baseline.node(shard_name).uid, first.node(shard_name).uid) + self.assertFalse(any(node.name.startswith("corpus-shard-") for node in baseline.nodes)) + self.assertEqual(first.node(corpus_name).uid, content_changed.node(corpus_name).uid) + self.assertNotEqual(first.node(corpus_name).uid, rerun.node(corpus_name).uid) + self.assertNotEqual(first.node(corpus_name).uid, moved.node(corpus_name).uid) + self.assertEqual(first.node(corpus_name).inputs, first.node(shard_name).inputs) + self.assertEqual(dict(first.node(corpus_name).command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve())) + self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(shard_name).command.env)) + self.assertIn("'[compatibility]'", first.node(corpus_name).command.stdin.decode()) + self.assertTrue(all( + "OBSERVER_TEST_CORPUS" not in dict(node.command.env) + for node in first.nodes if not node.name.startswith("corpus-shard-") + )) + self.assertFalse(any( + node.name.startswith(("test-shard-", "corpus-")) for node in build_only.nodes + )) + + def test_project_content_invalidates_only_its_build_and_consuming_shards(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + options = dict( + architectures=("x64",), + configurations=("Debug",), + runnable_architectures=("x64",), + test_shards=2, + ) + _discovery, before = self.staged_graphs(repository, toolchain, **options) + (repository / "src/renpy.cpp").write_text( + '#include "renpy.h"\n#include \n// changed\n', encoding="utf-8" + ) + _discovery, after = self.staged_graphs(repository, toolchain, **options) + (repository / "src/rpgmaker.h").write_text( + "#pragma once\n#include \n", encoding="utf-8" + ) + _discovery, unknown_package = self.staged_graphs( + repository, toolchain, **options + ) + + for name in ( + "restore-vcpkg-x64", + "build-rpgmaker-x64-debug", + "build-zanzarah-x64-debug", + "build-tests-x64-debug", + ): + self.assertEqual(before.node(name).uid, after.node(name).uid) + self.assertNotEqual( + before.node("build-renpy-x64-debug").uid, + after.node("build-renpy-x64-debug").uid, + ) + for index in range(2): + name = f"test-shard-x64-debug-{index}" + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + rpgmaker = "build-rpgmaker-x64-debug" + self.assertEqual(len(unknown_package.node(rpgmaker).inputs), 1) + self.assertNotEqual(after.node(rpgmaker).uid, unknown_package.node(rpgmaker).uid) + + def test_invalid_axes_are_rejected_before_graph_construction(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + with self.assertRaisesRegex(ValueError, "unsupported architecture"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + architectures=("mips",), + runnable_architectures=(), + ) + with self.assertRaisesRegex(ValueError, "unsupported configuration"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + configurations=("RelWithDebInfo",), + runnable_architectures=(), + ) + with self.assertRaisesRegex(ValueError, "test_shards"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + runnable_architectures=("x64",), + test_shards=0, + ) + with self.assertRaisesRegex(ValueError, "unsupported configuration"): + native_dependency_discovery_slice( + repository, toolchain, configurations=("RelWithDebInfo",) + ) + with self.assertRaisesRegex(ValueError, "unsupported architecture"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("mips",), + ) + with self.assertRaisesRegex(ValueError, "dependency discovery is required"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=(), + ) + corpus = root / "corpus" + corpus.mkdir() + with self.assertRaisesRegex(ValueError, "run nonce"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=corpus, run_nonce="", + ) + with self.assertRaises(FileNotFoundError): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=root / "missing", run_nonce="run", + ) + file_corpus = root / "corpus.bin" + file_corpus.touch() + with self.assertRaises(NotADirectoryError): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=file_corpus, run_nonce="run", + ) + + def test_native_build_requires_every_compiler_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + discovery = native_dependency_discovery_slice(repository, toolchain) + + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + native_graph( + repository, toolchain, discovery=discovery, manifests={}, + runnable_architectures=(), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_node.py b/tools/build/tests/test_node.py new file mode 100644 index 0000000..8be3c26 --- /dev/null +++ b/tools/build/tests/test_node.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.node import NodeFactory # noqa: E402 +from core.render import TemplateRenderer # noqa: E402 + + +class NodeFactoryTests(unittest.TestCase): + def test_runtime_environment_and_cwd_are_signed(self) -> None: + renderer = TemplateRenderer(BUILD_ROOT / "templates") + with tempfile.TemporaryDirectory() as temporary: + first = Path(temporary) / "first" + second = Path(temporary) / "second" + first.mkdir() + second.mkdir() + + def create(cwd: Path, value: str): + factory = NodeFactory(renderer, cwd, {"tool": "1"}, (("SETTING", value),)) + return factory.make( + "argv.json", + "example", + "slot", + {"argv": (str(Path(sys.executable).resolve()), "--version")}, + files={}, + config={"action": "probe"}, + ) + + baseline = create(first, "one") + changed_environment = create(first, "two") + changed_cwd = create(second, "one") + + self.assertNotEqual(baseline.uid, changed_environment.uid) + self.assertNotEqual(baseline.uid, changed_cwd.uid) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_package_graph.py b/tools/build/tests/test_package_graph.py new file mode 100644 index 0000000..cf3b90d --- /dev/null +++ b/tools/build/tests/test_package_graph.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock +import zipfile + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.package import PackageError, main as package_main # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.package import PackageArtifact, PackageSmokeArtifact, package_graph, package_outputs # noqa: E402 + + +MODULES = ("renpy", "rpgmaker", "zanzarah") +LICENSES = { + "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"), + "rpgmaker": ("Observer.txt", "rgssad.txt"), + "zanzarah": ("Observer.txt", "zanzapak.txt"), +} + + +def node(name: str, seed: str | None = None, *, inputs: tuple[str, ...] = ()) -> Node: + return Node(name, hashlib.md5((seed or name).encode()).hexdigest(), "build", Command(("build",)), inputs) + + +class PackageGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + for module, licenses in LICENSES.items(): + registration = root / f"src/modules/{module}/observer_user.ini" + registration.parent.mkdir(parents=True, exist_ok=True) + registration.write_text(f"[{module}]\n", encoding="utf-8") + for name in licenses: + path = root / "licenses" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"license:{name}\n", encoding="utf-8") + (root / "LICENSE.txt").write_text("project license\n", encoding="utf-8") + return root + + def fixture( + self, root: Path, architectures: tuple[str, ...] = ("x64", "arm64") + ) -> tuple[Path, Graph, tuple[PackageArtifact, ...]]: + repository = self.repository(root / "repo") + paths = BuildPaths(repository) + producers = tuple( + node(f"build-{module}-{architecture}-release") + for architecture in architectures + for module in MODULES + ) + audit_gates = tuple( + node(f"audit-{kind}-{architecture}-{module}", inputs=(producer.name,)) + for producer, (architecture, module) in zip( + producers, + ((architecture, module) for architecture in architectures for module in MODULES), + strict=True, + ) + for kind in ("pe", "binskim") + ) + upstream = Graph((*producers, *audit_gates), tuple(item.name for item in producers), {"build": 4}) + artifacts = tuple( + PackageArtifact( + architecture, + module, + producer, + paths.cas(producer.uid, producer.name).output / f"{module}.so", + paths.cas(producer.uid, producer.name).output / f"{module}.pdb", + ) + for producer, (architecture, module) in zip( + producers, + ((architecture, module) for architecture in architectures for module in MODULES), + strict=True, + ) + ) + return repository, upstream, artifacts + + def test_package_units_fan_out_and_only_inherent_aggregates_fan_in(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream, artifacts = self.fixture(Path(temporary)) + graph = package_graph(repository, upstream, artifacts, jobs=7) + paths = BuildPaths(repository) + outputs = package_outputs(repository, graph) + with self.assertRaisesRegex(ValueError, "no package outputs"): + package_outputs(repository, upstream) + + self.assertEqual(graph.pools, {"build": 4, "package": 7}) + self.assertEqual(len(graph.nodes) - len(upstream.nodes), 29) + self.assertEqual(graph.targets, ("package-manifest",)) + for artifact in artifacts: + suffix = f"{artifact.architecture}-{artifact.module}" + stage = graph.node(f"package-stage-{suffix}") + symbols = graph.node(f"package-symbol-stage-{suffix}") + archive = graph.node(f"package-archive-{suffix}") + validation = graph.node(f"package-validate-{suffix}") + gates = ( + f"audit-pe-{artifact.architecture}-{artifact.module}", + f"audit-binskim-{artifact.architecture}-{artifact.module}", + ) + self.assertEqual(stage.inputs, (artifact.producer.name, *gates)) + self.assertEqual(symbols.inputs, (artifact.producer.name, *gates)) + self.assertEqual(archive.inputs, (stage.name, *gates)) + self.assertEqual(validation.inputs, (archive.name, stage.name)) + self.assertEqual( + validation.command.argv[3:], + ("validate-module", artifact.architecture, artifact.module, + str(paths.cas(archive.uid, archive.name).output / f"{artifact.module}-{artifact.architecture}-dll.zip"), + str(paths.cas(stage.uid, stage.name).output)), + ) + self.assertEqual(stage.command.argv[:3], (sys.executable, "-m", "core.package")) + self.assertIn(str(artifact.binary), stage.command.argv) + self.assertIn(str(artifact.symbols), symbols.command.argv) + self.assertIn(str(paths.cas(stage.uid, stage.name).output), archive.command.argv) + + for architecture in ("arm64", "x64"): + combined = graph.node(f"package-symbols-{architecture}") + validation = graph.node(f"package-symbols-validate-{architecture}") + self.assertEqual( + combined.inputs, + tuple(f"package-symbol-stage-{architecture}-{module}" for module in MODULES), + ) + self.assertEqual( + validation.inputs, + (combined.name, *combined.inputs), + ) + aggregate = graph.node("package-manifest") + self.assertEqual(len(aggregate.inputs), 8) + self.assertTrue(all(name.startswith("package-validate-") or name.startswith("package-symbols-validate-") + for name in aggregate.inputs)) + self.assertEqual( + {Path(argument).name for argument in aggregate.command.argv[4:]}, + { + *(f"{module}-{architecture}-dll.zip" for architecture in ("arm64", "x64") for module in MODULES), + *(f"observer-modules-{architecture}-pdb.zip" for architecture in ("arm64", "x64")), + }, + ) + self.assertTrue(all(node.pool == "package" for node in graph.nodes[len(upstream.nodes) :])) + self.assertEqual( + outputs, + tuple( + paths.cas(graph.node(node_name).uid, node_name).output / archive_name + for architecture in ("arm64", "x64") + for node_name, archive_name in ( + *( (f"package-archive-{architecture}-{module}", f"{module}-{architecture}-dll.zip") + for module in MODULES ), + (f"package-symbols-{architecture}", f"observer-modules-{architecture}-pdb.zip"), + ) + ), + ) + + def test_repository_metadata_invalidates_only_consuming_package_partition(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream, artifacts = self.fixture(Path(temporary), ("x64",)) + before = package_graph(repository, upstream, artifacts) + (repository / "src/modules/renpy/observer_user.ini").write_text("changed\n", encoding="utf-8") + after = package_graph(repository, upstream, artifacts) + + changed = {current.name for current in before.nodes if current.uid != after.node(current.name).uid} + self.assertEqual( + changed, + { + "package-stage-x64-renpy", "package-archive-x64-renpy", + "package-validate-x64-renpy", "package-manifest", + }, + ) + + def test_package_smokes_run_independently_against_exact_archives(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, original, artifacts = self.fixture(Path(temporary), ("x64",)) + test_producer = node("build-tests-x64-release") + upstream = Graph( + original.nodes + (test_producer,), + original.targets + (test_producer.name,), + original.pools, + ) + paths = BuildPaths(repository) + executable = paths.cas(test_producer.uid, test_producer.name).output / "tests.exe" + graph = package_graph( + repository, + upstream, + artifacts, + smoke_tests=(PackageSmokeArtifact("x64", test_producer, executable),), + ) + + smokes = tuple(graph.node(f"package-smoke-x64-{module}") for module in MODULES) + for smoke, module in zip(smokes, MODULES, strict=True): + self.assertEqual( + smoke.inputs, + (f"package-validate-x64-{module}", test_producer.name), + ) + self.assertEqual(smoke.command.argv[3:6], ("smoke", "x64", module)) + archive = graph.node(f"package-archive-x64-{module}") + self.assertEqual( + Path(smoke.command.argv[6]), + paths.cas(archive.uid, archive.name).output / f"{module}-x64-dll.zip", + ) + self.assertIn(str(executable), smoke.command.argv) + manifest_inputs = set(graph.node("package-manifest").inputs) + self.assertEqual( + manifest_inputs, + {smoke.name for smoke in smokes} + | {f"package-validate-x64-{module}" for module in MODULES} + | {"package-symbols-validate-x64"}, + ) + + def test_invalid_artifacts_sets_and_pool_contracts_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream, artifacts = self.fixture(Path(temporary), ("x64",)) + + for jobs in (0, True, "four"): + with self.subTest(jobs=jobs), self.assertRaisesRegex(ValueError, "positive integer"): + package_graph(repository, upstream, artifacts, jobs=jobs) # type: ignore[arg-type] + with self.assertRaisesRegex(ValueError, "at least one"): + package_graph(repository, upstream, ()) + with self.assertRaisesRegex(ValueError, "duplicate"): + package_graph(repository, upstream, artifacts + (artifacts[0],)) + with self.assertRaisesRegex(ValueError, "identity"): + package_graph( + repository, + upstream, + ( + PackageArtifact( + "mips", "renpy", artifacts[0].producer, + artifacts[0].binary, artifacts[0].symbols, + ), + ), + ) + with self.assertRaisesRegex(ValueError, "identity"): + package_graph( + repository, + upstream, + ( + PackageArtifact( + "x64", "bad module", artifacts[0].producer, + artifacts[0].binary, artifacts[0].symbols, + ), + ), + ) + with self.assertRaisesRegex(ValueError, "complete module set"): + package_graph(repository, upstream, artifacts[:-1]) + + missing_gate = Graph( + tuple(node for node in upstream.nodes if node.name != "audit-pe-x64-renpy"), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "audit gates"): + package_graph(repository, missing_gate, artifacts) + pe_gate = upstream.node("audit-pe-x64-renpy") + proof = node("audit-proof-x64-renpy", inputs=(artifacts[0].producer.name,)) + indirect_gate = Node(pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (proof.name,)) + indirect = Graph( + (*tuple(item for item in upstream.nodes if item != pe_gate), proof, indirect_gate), + upstream.targets, + upstream.pools, + ) + self.assertEqual(package_graph(repository, indirect, artifacts).targets, ("package-manifest",)) + unrelated_gate = Node( + pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (artifacts[1].producer.name,) + ) + unrelated = Graph( + (*tuple(item for item in upstream.nodes if item != pe_gate), unrelated_gate), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "do not consume producer"): + package_graph(repository, unrelated, artifacts) + + impostor = node(artifacts[0].producer.name, "impostor") + bad_producer = PackageArtifact("x64", "renpy", impostor, artifacts[0].binary, artifacts[0].symbols) + with self.assertRaisesRegex(ValueError, "exact producer CAS"): + package_graph(repository, upstream, (bad_producer,) + artifacts[1:]) + wrong_path = PackageArtifact( + "x64", "renpy", artifacts[0].producer, artifacts[0].binary.parent / "wrong.so", artifacts[0].symbols + ) + with self.assertRaisesRegex(ValueError, "exact producer CAS"): + package_graph(repository, upstream, (wrong_path,) + artifacts[1:]) + + matching = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 4}) + self.assertEqual(package_graph(repository, matching, artifacts).pools["package"], 4) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + package_graph(repository, conflicting, artifacts) + + def test_invalid_package_smoke_artifacts_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, original, artifacts = self.fixture(Path(temporary), ("x64",)) + producer = node("build-tests-x64-release") + upstream = Graph(original.nodes + (producer,), original.targets, original.pools) + paths = BuildPaths(repository) + valid = PackageSmokeArtifact( + "x64", producer, paths.cas(producer.uid, producer.name).output / "tests.exe" + ) + cases = ( + ((valid, valid), "duplicate"), + ((PackageSmokeArtifact("x86", producer, valid.executable),), "exact test producer"), + ( + (PackageSmokeArtifact("x64", node(producer.name, "impostor"), valid.executable),), + "exact test producer", + ), + ( + (PackageSmokeArtifact("x64", producer, valid.executable.with_name("wrong.exe")),), + "exact test producer", + ), + ) + for smokes, message in cases: + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + package_graph(repository, upstream, artifacts, smoke_tests=smokes) + + +class PackageActionTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + return PackageGraphTests().repository(root) + + @staticmethod + def invoke(output: Path, *arguments: str) -> int: + output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False): + return package_main(arguments) + + def test_module_stage_archive_and_aggregate_are_reproducible(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + binary = root / "renpy.so" + binary.write_bytes(b"module") + stage = root / "stage" + self.assertEqual(0, self.invoke(stage, "stage-module", "x64", "renpy", str(binary), str(repository))) + + payload = stage / "payload" + expected = { + "renpy.so", "observer_user.ini", "docs/license.txt", + *(f"docs/thirdparty/{name}" for name in LICENSES["renpy"]), + } + self.assertEqual( + expected, + { + path.relative_to(payload).as_posix() + for path in payload.rglob("*") + if path.is_file() + }, + ) + document = json.loads((stage / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual( + ("x64", "module", "renpy"), + (document["architecture"], document["kind"], document["module"]), + ) + self.assertEqual(sorted(expected), [entry["name"] for entry in document["entries"]]) + + archive_one, archive_two = root / "archive-one", root / "archive-two" + self.invoke(archive_one, "archive-module", "x64", "renpy", str(stage)) + self.invoke(archive_two, "archive-module", "x64", "renpy", str(stage)) + first = archive_one / "renpy-x64-dll.zip" + second = archive_two / "renpy-x64-dll.zip" + self.assertEqual(first.read_bytes(), second.read_bytes()) + with zipfile.ZipFile(first) as archive: + self.assertEqual(sorted(expected), archive.namelist()) + self.assertTrue(all(item.date_time == (1980, 1, 1, 0, 0, 0) for item in archive.infolist())) + self.assertEqual(b"module", archive.read("renpy.so")) + + validation = root / "validation" + self.assertEqual( + 0, + self.invoke(validation, "validate-module", "x64", "renpy", str(first), str(stage)), + ) + proof = json.loads((validation / "validation.json").read_text(encoding="utf-8")) + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), proof["sha256"]) + self.assertEqual(sorted(expected), [entry["name"] for entry in proof["entries"]]) + + tampered = root / "tampered.zip" + with zipfile.ZipFile(first) as source, zipfile.ZipFile(tampered, "w") as target: + for name in source.namelist(): + target.writestr(name, b"changed" if name == "renpy.so" else source.read(name)) + with self.assertRaisesRegex(PackageError, "exact stage manifests"): + self.invoke(root / "tampered-validation", "validate-module", "x64", "renpy", str(tampered), str(stage)) + duplicate = root / "duplicate.zip" + with self.assertWarns(UserWarning), zipfile.ZipFile(duplicate, "w") as archive: + archive.writestr("renpy.so", b"first") + archive.writestr("renpy.so", b"second") + for index, invalid in enumerate((duplicate, root / "invalid.zip")): + if not invalid.exists(): + invalid.write_bytes(b"not a zip") + with self.subTest(invalid=invalid), self.assertRaisesRegex(PackageError, "exact stage manifests"): + self.invoke(root / f"invalid-validation-{index}", "validate-module", + "x64", "renpy", str(invalid), str(stage)) + + aggregate = root / "aggregate" + self.invoke(aggregate, "aggregate", str(first)) + packages = json.loads((aggregate / "packages.json").read_text(encoding="utf-8")) + self.assertEqual("renpy-x64-dll.zip", packages[0]["name"]) + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), packages[0]["sha256"]) + + def test_symbol_stages_fan_into_one_deterministic_architecture_archive(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + stages = [] + for module in MODULES: + symbol = root / f"{module}.pdb" + symbol.write_bytes(module.encode()) + stage = root / f"stage-{module}" + self.invoke(stage, "stage-symbol", "arm64", module, str(symbol)) + stages.append(stage) + output = root / "symbols" + self.invoke(output, "archive-symbols", "arm64", *(str(stage) for stage in stages)) + with zipfile.ZipFile(output / "observer-modules-arm64-pdb.zip") as archive: + self.assertEqual([f"{module}.pdb" for module in MODULES], archive.namelist()) + self.assertEqual(b"zanzarah", archive.read("zanzarah.pdb")) + validation = root / "symbols-validation" + archive = output / "observer-modules-arm64-pdb.zip" + self.assertEqual( + 0, + self.invoke(validation, "validate-symbols", "arm64", str(archive), *(str(stage) for stage in stages)), + ) + self.assertEqual( + hashlib.sha256(archive.read_bytes()).hexdigest(), + json.loads((validation / "validation.json").read_text())["sha256"], + ) + + def test_smoke_extracts_and_runs_the_exact_archived_module(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / "renpy-x64-dll.zip" + with zipfile.ZipFile(archive, "w") as package: + package.writestr("renpy.so", b"exact packaged module") + tests = root / "tests.exe" + tests.write_bytes(b"test runner") + output = root / "smoke" + + with mock.patch("core.package.subprocess.run") as run: + self.invoke(output, "smoke", "x64", "renpy", str(archive), str(tests)) + + module = output / "renpy.so" + self.assertEqual(module.read_bytes(), b"exact packaged module") + run.assert_called_once() + arguments = run.call_args.args[0] + self.assertEqual(arguments[:2], [str(tests), "[package-smoke]"]) + self.assertEqual(run.call_args.kwargs["cwd"], output) + self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_MODULE"], str(module)) + self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_FORMAT"], "renpy") + + missing = root / "missing.zip" + with zipfile.ZipFile(missing, "w") as package: + package.writestr("other.so", b"wrong module") + with self.assertRaisesRegex(PackageError, "archive has no renpy.so"): + self.invoke(root / "missing-smoke", "smoke", "x64", "renpy", str(missing), str(tests)) + + def test_manifest_mismatch_duplicate_archive_and_missing_output_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + binary = root / "renpy.so" + binary.write_bytes(b"module") + stage = root / "stage" + self.invoke(stage, "stage-module", "x86", "renpy", str(binary), str(repository)) + (stage / "payload/extra.obj").write_bytes(b"unexpected") + with self.assertRaisesRegex(PackageError, "manifest does not match"): + self.invoke(root / "bad-archive", "archive-module", "x86", "renpy", str(stage)) + + archive = root / "same.zip" + archive.write_bytes(b"same") + with self.assertRaisesRegex(PackageError, "duplicate archive name"): + self.invoke(root / "bad-aggregate", "aggregate", str(archive), str(archive)) + + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(PackageError, "OBSERVER_OUT_DIR"): + package_main(("aggregate", "missing.zip")) + + def test_module_validation_and_module_entrypoint_dispatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + symbol = root / "renpy.pdb" + symbol.write_bytes(b"symbols") + stage = root / "stage" + self.invoke(stage, "stage-symbol", "x64", "renpy", str(symbol)) + with self.assertRaisesRegex(PackageError, "expected module set"): + self.invoke(root / "bad-symbols", "archive-symbols", "x64", str(stage)) + + output = root / "entrypoint" + output.mkdir() + with ( + mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False), + mock.patch.object(sys, "argv", ["package.py", "aggregate", str(symbol)]), + self.assertWarnsRegex(RuntimeWarning, "core.package"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.package", run_name="__main__") + self.assertEqual(0, raised.exception.code) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_paths.py b/tools/build/tests/test_paths.py new file mode 100644 index 0000000..5774b70 --- /dev/null +++ b/tools/build/tests/test_paths.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from core.paths import BuildPaths, PathSafetyError + + +UID = "0123456789abcdef0123456789abcdef" + + +class BuildPathsTests(unittest.TestCase): + def test_prepare_creates_only_cas_and_work_at_output_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + paths.prepare() + + self.assertEqual(paths.output_root, repository / "out") + self.assertEqual( + {child.name for child in paths.output_root.iterdir()}, + {"cas", "work"}, + ) + self.assertTrue(paths.cas_root.is_dir()) + self.assertTrue(paths.work_root.is_dir()) + self.assertTrue(paths.locks_root.is_dir()) + + def test_cas_paths_expose_entry_output_touch_and_log(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + entry = paths.cas(UID, "analyze-renpy.pickle") + + self.assertEqual( + entry.entry, + repository / "out" / "cas" / f"{UID}-analyze-renpy.pickle", + ) + self.assertEqual(entry.output, entry.entry / "out") + self.assertEqual(entry.touch, entry.entry / "touch") + self.assertEqual(entry.log, entry.entry / "log.txt") + + def test_run_work_and_lock_paths_are_confined_under_work(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + self.assertEqual( + paths.run_work("20260801-abcdef"), + repository / "out" / "work" / "20260801-abcdef", + ) + self.assertEqual( + paths.lock(UID), + repository / "out" / "work" / ".locks" / f"{UID}.lock", + ) + self.assertEqual( + paths.coordination_lock(), + repository / "out" / "work" / ".locks" / "coordination.lock", + ) + self.assertEqual( + paths.lease("20260801-abcdef"), + repository / "out" / "work" / ".locks" / "run-20260801-abcdef.lease", + ) + + def test_invalid_uid_and_run_identifier_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + for invalid_uid in ("", "ABCDEF" * 5 + "AB", "../escape", "0" * 31): + with self.subTest(uid=invalid_uid): + with self.assertRaises(PathSafetyError): + paths.cas(invalid_uid, "node") + + for invalid_node in ("", "Uppercase", ".hidden", "../escape", "with/slash"): + with self.subTest(node=invalid_node): + with self.assertRaises(PathSafetyError): + paths.cas(UID, invalid_node) + + for invalid_run in ("", ".", "..", "../escape", "with/slash", "a" * 129): + with self.subTest(run=invalid_run): + with self.assertRaises(PathSafetyError): + paths.run_work(invalid_run) + with self.assertRaises(PathSafetyError): + paths.lease(invalid_run) + + def test_cas_node_slug_is_limited_to_128_ascii_characters(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + maximum = paths.cas(UID, "a" * 128) + + self.assertTrue(maximum.entry.name.endswith("-" + "a" * 128)) + with self.assertRaisesRegex(PathSafetyError, "node slug.*128"): + paths.cas(UID, "a" * 129) + + def test_confined_path_rejects_parent_escape_and_unexpected_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + with self.assertRaisesRegex(PathSafetyError, "outside allowed root"): + paths.require_confined(paths.cas_root / ".." / "work", paths.cas_root) + with self.assertRaisesRegex(PathSafetyError, "unexpected allowed root"): + paths.require_confined(repository / "elsewhere", repository / "elsewhere") + with self.assertRaisesRegex(PathSafetyError, "outside repository"): + paths._reject_existing_reparse_points(repository.parent / "outside") + + def test_existing_reparse_component_and_leaf_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + regular = BuildPaths(repository) + regular.prepare() + entry = regular.cas(UID, "node") + entry.entry.mkdir() + entry.touch.touch() + + reparse_paths = { + regular.cas_root, + entry.touch, + } + + def is_reparse(path: Path) -> bool: + return path in reparse_paths + + with patch("core.paths._is_reparse", side_effect=is_reparse): + paths = BuildPaths(repository) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + paths.cas(UID, "node") + + reparse_paths.remove(regular.cas_root) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + paths.require_confined(entry.touch, paths.cas_root) + + def test_repository_must_be_an_existing_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing" + with self.assertRaisesRegex(PathSafetyError, "existing directory"): + BuildPaths(missing) + + file_path = Path(temporary) / "file" + file_path.touch() + with self.assertRaisesRegex(PathSafetyError, "existing directory"): + BuildPaths(file_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_python_coverage_graph.py b/tools/build/tests/test_python_coverage_graph.py new file mode 100644 index 0000000..8fd1fec --- /dev/null +++ b/tools/build/tests/test_python_coverage_graph.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.python_coverage import main as coverage_main # noqa: E402 +from graphs.python_coverage import python_coverage_graph # noqa: E402 + + +class PythonCoverageGraphTests(unittest.TestCase): + def repository(self, root: Path, executable: bool = True) -> Path: + build = root / "tools/build" + for relative, content in ( + ("core/example.py", "VALUE = 1\n"), + ("graphs/example.py", "VALUE = 2\n"), + ("tests/test_example.py", "pass\n"), + ("driver.py", "VALUE = 4\n"), + ("main.py", "VALUE = 3\n"), + ("pyproject.toml", "[project]\nname='fixture'\n"), + ("uv.lock", "fixture\n"), + ): + path = build / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + coverage = build / ".venv/Scripts/coverage.exe" + package = build / ".venv/Lib/site-packages/coverage" + package.mkdir(parents=True) + (package / "version.py").write_text("__version__ = 'fixture'\n", encoding="utf-8") + cache = package / "__pycache__" + cache.mkdir() + (cache / "version.pyc").write_bytes(b"derived") + if executable: + coverage.parent.mkdir(parents=True) + coverage.write_bytes(b"coverage-launcher") + else: + coverage.mkdir(parents=True) + return root + + def test_single_gate_signs_all_first_party_tests_config_lock_and_exact_local_coverage(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = self.repository(Path(temporary) / "repo") + before = python_coverage_graph(repository) + node = before.node("python-coverage") + + self.assertEqual(before.targets, (node.name,)) + self.assertEqual(dict(before.pools), {"python-coverage": 1}) + self.assertEqual(dict(node.command.env), {"PYTHONDONTWRITEBYTECODE": "1"}) + self.assertEqual( + node.command.argv, + (sys.executable, "-m", "core.python_coverage", + str(repository / "tools/build/.venv/Scripts/coverage.exe"), str(repository / "tools/build")), + ) + (repository / "tools/build/tests/test_example.py").write_text("changed\n", encoding="utf-8") + changed_test = python_coverage_graph(repository) + (repository / "tools/build/tests/test_example.py").write_text("pass\n", encoding="utf-8") + (repository / "tools/build/driver.py").write_text("changed\n", encoding="utf-8") + changed_driver = python_coverage_graph(repository) + (repository / "tools/build/driver.py").write_text("VALUE = 4\n", encoding="utf-8") + config = repository / "tools/build/pyproject.toml" + original_config = config.read_text(encoding="utf-8") + config.write_text(original_config + "\n# changed\n", encoding="utf-8") + changed_config = python_coverage_graph(repository) + config.write_text(original_config, encoding="utf-8") + (repository / "tools/build/.venv/Scripts/coverage.exe").write_bytes(b"changed-coverage") + changed_tool = python_coverage_graph(repository) + (repository / "tools/build/.venv/Scripts/coverage.exe").write_bytes(b"coverage-launcher") + (repository / "tools/build/.venv/Lib/site-packages/coverage/version.py").write_text( + "__version__ = 'changed'\n", encoding="utf-8" + ) + changed_package = python_coverage_graph(repository) + + self.assertNotEqual(node.uid, changed_test.node(node.name).uid) + self.assertNotEqual(node.uid, changed_driver.node(node.name).uid) + self.assertNotEqual(node.uid, changed_config.node(node.name).uid) + self.assertNotEqual(node.uid, changed_tool.node(node.name).uid) + self.assertNotEqual(node.uid, changed_package.node(node.name).uid) + + def test_missing_or_non_file_project_coverage_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + missing = self.repository(root / "missing") + (missing / "tools/build/.venv/Scripts/coverage.exe").unlink() + with self.assertRaises(FileNotFoundError): + python_coverage_graph(missing) + directory = self.repository(root / "directory", executable=False) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + python_coverage_graph(directory) + bad_package = self.repository(root / "bad-package") + package = bad_package / "tools/build/.venv/Lib/site-packages/coverage" + (package / "version.py").unlink() + (package / "__pycache__/version.pyc").unlink() + (package / "__pycache__").rmdir() + package.rmdir() + package.write_text("not a directory", encoding="utf-8") + with self.assertRaisesRegex(FileNotFoundError, "not a directory"): + python_coverage_graph(bad_package) + + +class PythonCoverageWorkerTests(unittest.TestCase): + def project(self, root: Path) -> Path: + for relative, content in ( + ("core/__init__.py", ""), + ("core/value.py", "VALUE = 1\n"), + ("graphs/__init__.py", ""), + ("graphs/value.py", "VALUE = 2\n"), + ("driver.py", "VALUE = 4\n"), + ("main.py", "VALUE = 3\n"), + ("pyproject.toml", (BUILD_ROOT / "pyproject.toml").read_text(encoding="utf-8")), + ("tests/test_all.py", "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\nimport driver\nimport main\nclass T(unittest.TestCase):\n def test_all(self): self.assertEqual((CORE, GRAPH, driver.VALUE, main.VALUE), (1, 2, 4, 3))\n"), + ): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def test_exact_cli_publishes_line_branch_reports_from_isolated_work_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project, work, output = self.project(root / "project"), root / "work", root / "output" + work.mkdir() + output.mkdir() + environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)} + coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe" + with mock.patch.dict(os.environ, environment, clear=False): + self.assertEqual(0, coverage_main((str(coverage), str(project)))) + + document = json.loads((output / "coverage.json").read_text(encoding="utf-8")) + self.assertEqual(100.0, document["totals"]["percent_covered"]) + self.assertTrue((output / "coverage.xml").is_file()) + self.assertIn("100%", (output / "coverage.txt").read_text(encoding="utf-8")) + self.assertEqual( + (output / "coverage.toml").read_bytes(), + (project / "pyproject.toml").read_bytes(), + ) + self.assertTrue((work / ".coverage").is_file()) + self.assertEqual((output / ".coverage").read_bytes(), (work / ".coverage").read_bytes()) + self.assertFalse((project / ".coverage").exists()) + + entry_work, entry_output = root / "entry-work", root / "entry-output" + entry_work.mkdir() + entry_output.mkdir() + with ( + mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(entry_work), + "OBSERVER_OUT_DIR": str(entry_output)}, clear=False), + mock.patch.object(sys, "argv", ["python_coverage.py", str(coverage), str(project)]), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_path(str(BUILD_ROOT / "core/python_coverage.py"), run_name="__main__") + self.assertEqual(0, raised.exception.code) + + def test_below_100_fails_after_publishing_evidence_and_environment_is_required(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project, work, output = self.project(root / "project"), root / "work", root / "output" + work.mkdir() + output.mkdir() + (project / "tests/test_all.py").write_text( + "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\n" + "class T(unittest.TestCase):\n def test_packages(self): self.assertEqual((CORE, GRAPH), (1, 2))\n", + encoding="utf-8", + ) + coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe" + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}): + with self.assertRaises(subprocess.CalledProcessError): + coverage_main((str(coverage), str(project))) + self.assertLess(json.loads((output / "coverage.json").read_text())["totals"]["percent_covered"], 100) + self.assertTrue((output / "coverage.txt").is_file()) + + (project / "core/value.py").write_text( + "def choose(first, second):\n value = 0\n if first:\n value = 1\n if second:\n value = 2\n return value\n", + encoding="utf-8", + ) + (project / "tests/test_all.py").write_text( + "import unittest\nfrom core.value import choose\nfrom graphs.value import VALUE\nimport driver\nimport main\n" + "class T(unittest.TestCase):\n def test_one_branch(self): self.assertEqual((choose(True, True), VALUE, driver.VALUE, main.VALUE), (2, 2, 4, 3))\n", + encoding="utf-8", + ) + branch_work, branch_output = root / "branch-work", root / "branch-output" + branch_work.mkdir() + branch_output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(branch_work), + "OBSERVER_OUT_DIR": str(branch_output)}): + with self.assertRaises(subprocess.CalledProcessError): + coverage_main((str(coverage), str(project))) + totals = json.loads((branch_output / "coverage.json").read_text())["totals"] + self.assertEqual(0, totals["missing_lines"]) + self.assertGreater(totals["missing_branches"], 0) + + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT))) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + work, output, tool, build_file = root / "work", root / "output", root / "coverage", root / "build" + work.mkdir() + output.mkdir() + tool.mkdir() + build_file.touch() + environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)} + for arguments in ((tool, BUILD_ROOT), (BUILD_ROOT / ".venv/Scripts/coverage.exe", build_file)): + with self.subTest(arguments=arguments), mock.patch.dict(os.environ, environment, clear=False): + with self.assertRaises(FileNotFoundError): + coverage_main(tuple(map(str, arguments))) + missing_config = root / "missing-config" + missing_config.mkdir() + with mock.patch.dict(os.environ, environment, clear=False), self.assertRaises(FileNotFoundError): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(missing_config))) + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(root / "missing"), + "OBSERVER_OUT_DIR": str(output)}, clear=True): + with self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_quality_tools.py b/tools/build/tests/test_quality_tools.py new file mode 100644 index 0000000..a01329f --- /dev/null +++ b/tools/build/tests/test_quality_tools.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError, dataclass +import hashlib +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +import sys + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.quality_tools import ( # noqa: E402 + QualityTools, + ResolvedDirectory, + ResolvedTool, + SanitizerRuntimes, + discover_quality_tools, + resolve_binskim, + resolve_dumpbin, + resolve_llvm, + resolve_asan_runtimes, + resolve_sanitizer_runtimes, + resolve_tool, + resolve_ubsan_runtime, + resolve_umdh, +) + + +@dataclass(frozen=True) +class FakeToolchain: + installation: Path + llvm_dir: Path + identity: tuple[tuple[str, str], ...] + + +class QualityToolsTests(unittest.TestCase): + def fixture(self, root: Path, *, kit11: bool = True) -> tuple[FakeToolchain, Path, Path]: + installation, llvm = root / "Visual Studio", root / "LLVM" + version = "14.44.35207" + paths = ( + llvm / "bin/clang-cl.exe", + llvm / "bin/clang-scan-deps.exe", + llvm / "bin/llvm-cov.exe", + llvm / "bin/llvm-profdata.exe", + installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe", + ) + for index, path in enumerate(paths): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"tool-{index}".encode()) + program_files = root / "Program Files (x86)" + kit = "11" if kit11 else "10" + umdh = program_files / f"Windows Kits/{kit}/Debuggers/x64/umdh.exe" + umdh.parent.mkdir(parents=True) + umdh.write_bytes(b"umdh") + binskim = root / "shims/BinSkim.exe" + binskim.parent.mkdir() + binskim.write_bytes(b"binskim") + identity = (("clang_tidy_version", "19.1.5"), ("vc_tools_version", version)) + return FakeToolchain(installation, llvm, identity), binskim, program_files + + def runtime_fixture( + self, toolchain: FakeToolchain, llvm_version: str = "19" + ) -> tuple[Path, tuple[Path, Path]]: + version = dict(toolchain.identity)["vc_tools_version"] + asan = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64" + for architecture, name in ( + ("x86", "clang_rt.asan_dynamic-i386.dll"), + ("x64", "clang_rt.asan_dynamic-x86_64.dll"), + ): + path = asan / architecture / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"asan-{architecture}".encode()) + directory = toolchain.llvm_dir / f"lib/clang/{llvm_version}/lib/windows" + libraries = tuple( + directory / name + for name in ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", + ) + ) + directory.mkdir(parents=True) + for index, library in enumerate(libraries): + library.write_bytes(f"ubsan-{index}".encode()) + return directory, libraries # type: ignore[return-value] + + def test_resolves_exact_consumers_with_canonical_streaming_sha256_identities(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary)) + with ( + mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), + mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)) as which, + mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")), + ): + tools = discover_quality_tools(toolchain) # type: ignore[arg-type] + expected_digests = {} + for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + ): + with tool.path.open("rb") as stream: + expected_digests[tool.path] = hashlib.file_digest(stream, "sha256").hexdigest() + + which.assert_called_once_with("binskim") + self.assertIsInstance(tools, QualityTools) + expected_names = ( + "clang-cl.exe", "clang-scan-deps.exe", "llvm-cov.exe", "llvm-profdata.exe", + "dumpbin.exe", "BinSkim.exe", "umdh.exe", + ) + self.assertEqual( + tuple(tool.path.name for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + )), + expected_names, + ) + for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + ): + self.assertEqual(tool.identity[0], ("path", str(tool.path))) + self.assertEqual(tool.identity[1][0], "sha256") + self.assertEqual(tool.identity[1][1], expected_digests[tool.path]) + self.assertIn("Windows Kits\\11", str(tools.umdh.path)) + with self.assertRaises(FrozenInstanceError): + tools.binskim = ResolvedTool(tools.binskim.path, tools.binskim.identity) # type: ignore[misc] + + def test_windows_kit_10_is_the_deterministic_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary), kit11=False) + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=str(binskim) + ): + tools = discover_quality_tools(toolchain) # type: ignore[arg-type] + self.assertIn("Windows Kits\\10", str(tools.umdh.path)) + + def test_selective_resolvers_are_lazy_and_match_the_aggregate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary)) + (toolchain.llvm_dir / "bin/llvm-cov.exe").unlink() + with ( + mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), + mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)), + ): + self.assertEqual(resolve_llvm(toolchain, "clang-cl").path.name, "clang-cl.exe") + self.assertEqual( + resolve_llvm(toolchain, "clang-scan-deps").path.name, + "clang-scan-deps.exe", + ) + self.assertEqual(resolve_dumpbin(toolchain).path.name, "dumpbin.exe") + self.assertEqual(resolve_binskim().path, binskim.resolve()) + self.assertEqual(resolve_umdh().path.name, "umdh.exe") + with self.assertRaisesRegex(FileNotFoundError, "missing llvm-cov"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + with self.assertRaisesRegex(ValueError, "unsupported LLVM tool: clang-tidy"): + resolve_llvm(toolchain, "clang-tidy") + + def test_sanitizer_runtimes_are_exact_typed_and_content_addressed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + directory, libraries = self.runtime_fixture(toolchain) + with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")): + runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + + self.assertIsInstance(runtimes, SanitizerRuntimes) + self.assertEqual(runtimes.asan_x86.path.name, "clang_rt.asan_dynamic-i386.dll") + self.assertEqual(runtimes.asan_x64.path.name, "clang_rt.asan_dynamic-x86_64.dll") + self.assertIsInstance(runtimes.ubsan, ResolvedDirectory) + self.assertEqual(runtimes.ubsan.path, directory.resolve()) + self.assertEqual(tuple(tool.path for tool in runtimes.ubsan.files), libraries) + identity = dict(runtimes.ubsan.identity) + self.assertEqual(identity["path"], str(directory.resolve())) + for tool in runtimes.ubsan.files: + name = tool.path.name + self.assertEqual(identity[f"{name}.path"], str(tool.path)) + self.assertEqual(identity[f"{name}.sha256"], dict(tool.identity)["sha256"]) + with self.assertRaises(FrozenInstanceError): + runtimes.asan_x86 = runtimes.asan_x64 # type: ignore[misc] + + def test_sanitizer_resolvers_load_only_the_requested_runtime_family(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + directory, libraries = self.runtime_fixture(toolchain) + version = dict(toolchain.identity)["vc_tools_version"] + asan_root = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64" + + libraries[0].unlink() + selected = resolve_asan_runtimes(toolchain, ("x64",)) # type: ignore[arg-type] + self.assertEqual(tuple(selected), (("x64", selected[0][1]),)) + self.assertEqual(selected[0][1].path.name, "clang_rt.asan_dynamic-x86_64.dll") + with self.assertRaisesRegex(ValueError, "unsupported ASan architecture: arm64"): + resolve_asan_runtimes(toolchain, ("arm64",)) # type: ignore[arg-type] + + libraries[0].write_bytes(b"ubsan-0") + for runtime in asan_root.rglob("*.dll"): + runtime.unlink() + ubsan = resolve_ubsan_runtime(toolchain) # type: ignore[arg-type] + self.assertEqual(ubsan.path, directory.resolve()) + + def test_sanitizer_runtime_errors_name_the_exact_missing_input(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + toolchain, _binskim, _program_files = self.fixture(root) + directory, libraries = self.runtime_fixture(toolchain) + version = dict(toolchain.identity)["vc_tools_version"] + missing_asan = ( + toolchain.installation + / f"VC/Tools/MSVC/{version}/bin/Hostx64/x86/clang_rt.asan_dynamic-i386.dll" + ) + missing_asan.unlink() + with self.assertRaisesRegex(FileNotFoundError, "missing MSVC ASan x86 runtime"): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + missing_asan.write_bytes(b"asan-x86") + libraries[1].unlink() + with self.assertRaisesRegex( + FileNotFoundError, "missing UBSan runtime clang_rt.ubsan_standalone_cxx-x86_64.lib" + ): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + libraries[0].unlink() + directory.rmdir() + with self.assertRaisesRegex(FileNotFoundError, "missing UBSan runtime directory"): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + + no_version = FakeToolchain(toolchain.installation, toolchain.llvm_dir, ()) + with self.assertRaisesRegex(FileNotFoundError, "missing MSVC vc_tools_version identity"): + resolve_dumpbin(no_version) # type: ignore[arg-type] + + def test_full_llvm_version_runtime_precedes_the_major_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + self.runtime_fixture(toolchain) + directory, _libraries = self.runtime_fixture(toolchain, "19.1.5") + runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + self.assertEqual(runtimes.ubsan.path, directory.resolve()) + + def test_each_missing_tool_is_named_precisely(self) -> None: + cases = ( + ("clang-cl", "LLVM/bin/clang-cl.exe"), + ("clang-scan-deps", "LLVM/bin/clang-scan-deps.exe"), + ("llvm-cov", "LLVM/bin/llvm-cov.exe"), + ("llvm-profdata", "LLVM/bin/llvm-profdata.exe"), + ("dumpbin", "Visual Studio/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64/dumpbin.exe"), + ) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for index, (name, relative) in enumerate(cases): + toolchain, binskim, program_files = self.fixture(root / str(index)) + (root / str(index) / relative).unlink() + with self.subTest(name=name), mock.patch.dict( + os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False + ), mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)), self.assertRaisesRegex( + FileNotFoundError, f"missing {name}" + ): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + toolchain, _binskim, program_files = self.fixture(root / "binskim") + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=None + ), self.assertRaisesRegex(FileNotFoundError, "missing BinSkim"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + toolchain, binskim, program_files = self.fixture(root / "umdh") + (program_files / "Windows Kits/11/Debuggers/x64/umdh.exe").unlink() + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=str(binskim) + ), self.assertRaisesRegex(FileNotFoundError, "missing UMDH"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + def test_resolve_tool_rejects_non_files_and_content_changes_identity(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "tool.exe" + path.write_bytes(b"one") + before = resolve_tool(path, "example") + path.write_bytes(b"two") + after = resolve_tool(path, "example") + with self.assertRaisesRegex(FileNotFoundError, "missing absent"): + resolve_tool(None, "absent") + with self.assertRaisesRegex(FileNotFoundError, "missing directory"): + resolve_tool(root, "directory") + self.assertNotEqual(before.identity, after.identity) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_recipe.py b/tools/build/tests/test_recipe.py new file mode 100644 index 0000000..3f3b5b9 --- /dev/null +++ b/tools/build/tests/test_recipe.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import GraphError # noqa: E402 +from core.recipe import Recipe, RecipeError # noqa: E402 + + +def rendered_recipe(**overrides: object) -> str: + document: dict[str, object] = { + "name": "analyze-renpy-x64", + "pool": "slot", + "inputs": ["compile-renpy-x64"], + "script": { + "exec": ["pwsh.exe", "-NoProfile", "-Command", "-"], + "data": "Write-Output 'привет'\r\n", + }, + } + document.update(overrides) + return json.dumps(document, ensure_ascii=False) + + +class RecipeTests(unittest.TestCase): + def test_repository_recipe_is_parsed_without_reordering_process_data(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + + self.assertEqual(recipe.name, "analyze-renpy-x64") + self.assertEqual(recipe.pool, "slot") + self.assertEqual(recipe.inputs, ("compile-renpy-x64",)) + self.assertEqual(recipe.argv, ("pwsh.exe", "-NoProfile", "-Command", "-")) + self.assertEqual(recipe.data, "Write-Output 'привет'\r\n".encode()) + + def test_node_bridge_uses_signed_uid_direct_dependencies_and_process_data(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + current = recipe.to_node( + uid="0123456789abcdef0123456789abcdef", + env={"OBSERVER_OUT_DIR": r"C:\repo\out", "ZED": "last"}, + cwd=r"C:\repo\out\work\one", + ) + + self.assertEqual(current.name, recipe.name) + self.assertEqual(current.inputs, ("compile-renpy-x64",)) + self.assertEqual(current.command.argv, recipe.argv) + self.assertEqual(current.command.stdin, recipe.data) + self.assertEqual(current.command.cwd, r"C:\repo\out\work\one") + self.assertEqual( + current.command.env, + (("OBSERVER_OUT_DIR", r"C:\repo\out"), ("ZED", "last")), + ) + + def test_only_required_repository_fields_are_interpreted(self) -> None: + recipe = Recipe.parse(rendered_recipe(metadata={"owner": "repository"})) + duplicate_name = rendered_recipe().replace( + '{"name": "analyze-renpy-x64",', + '{"name": "old", "name": "analyze-renpy-x64",', + ) + + self.assertEqual(recipe.name, "analyze-renpy-x64") + self.assertEqual(Recipe.parse(duplicate_name).name, "analyze-renpy-x64") + + def test_invalid_json_or_missing_required_fields_are_rejected(self) -> None: + with self.assertRaises(RecipeError): + Recipe.parse("not json") + + valid = json.loads(rendered_recipe()) + for field in ("name", "pool", "inputs", "script"): + document = dict(valid) + del document[field] + with self.subTest(field=field), self.assertRaises(RecipeError): + Recipe.parse(json.dumps(document)) + + for field in ("exec", "data"): + script = dict(valid["script"]) + del script[field] + with self.subTest(script_field=field), self.assertRaises(RecipeError): + Recipe.parse(json.dumps({**valid, "script": script})) + + def test_graph_and_process_descriptors_remain_validation_boundaries(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + + with self.assertRaises(GraphError): + recipe.to_node(uid="not-md5") + with self.assertRaises(GraphError): + Recipe.parse(rendered_recipe(name="unsafe name")).to_node(uid="0" * 32) + with self.assertRaises(GraphError): + Recipe.parse( + rendered_recipe(script={"exec": ["bad\0argv"], "data": ""}) + ).to_node(uid="0" * 32) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_render.py b/tools/build/tests/test_render.py new file mode 100644 index 0000000..4b5d684 --- /dev/null +++ b/tools/build/tests/test_render.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from jinja2 import UndefinedError + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.render import TemplateRenderer, ps_quote # noqa: E402 + + +class TemplateRendererTests(unittest.TestCase): + def setUp(self) -> None: + self.templates = BUILD_ROOT / "templates" + self.renderer = TemplateRenderer(self.templates) + + def variables(self) -> dict[str, object]: + return { + "name": "build-renpy-x64", + "pool": "slot", + "inputs": ["src/modules/renpy/renpy.vcxproj"], + "pwsh": "pwsh.exe", + "msbuild": r"C:\Program Files\O'Brien Tools\MSBuild.exe", + "project": r"C:\repo\RPG's\renpy.vcxproj", + "target": "Build", + "configuration": "Release", + "platform": "x64", + "msbuild_args": ["/p:WarningsAsErrors=true"], + } + + def test_msbuild_leaf_inherits_complete_json_recipe(self) -> None: + rendered = self.renderer.render("msbuild.ps1", self.variables()) + recipe = json.loads(rendered) + + self.assertEqual(recipe["name"], "build-renpy-x64") + self.assertEqual(recipe["pool"], "slot") + self.assertNotIn("outputs", recipe) + self.assertEqual( + recipe["script"]["exec"], + [ + "pwsh.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$ErrorActionPreference = 'Stop'; " + "& ([ScriptBlock]::Create([Console]::In.ReadToEnd()))", + ], + ) + self.assertNotIn("shell", recipe["script"]) + self.assertIn( + "'C:\\Program Files\\O''Brien Tools\\MSBuild.exe'", + recipe["script"]["data"], + ) + self.assertIn("'C:\\repo\\RPG''s\\renpy.vcxproj'", recipe["script"]["data"]) + self.assertIn("'/p:Configuration=Release'", recipe["script"]["data"]) + self.assertIn("'/p:WarningsAsErrors=true'", recipe["script"]["data"]) + self.assertIn("$env:OBSERVER_OUT_DIR", recipe["script"]["data"]) + self.assertIn("$env:OBSERVER_BUILD_DIR", recipe["script"]["data"]) + self.assertNotIn(r"C:\repo\out\cas", recipe["script"]["data"]) + self.assertIn("${LASTEXITCODE}: $FilePath", recipe["script"]["data"]) + + def test_powershell_stdin_transport_reports_parser_errors(self) -> None: + variables = self.variables() + variables["pwsh"] = shutil.which("pwsh") + argv = json.loads(self.renderer.render("msbuild.ps1", variables))["script"][ + "exec" + ] + result = subprocess.run( + argv, + input=b"$invalid:\n", + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + + def test_missing_variable_fails_before_a_recipe_is_created(self) -> None: + variables = self.variables() + del variables["platform"] + + with self.assertRaisesRegex(UndefinedError, "platform.*undefined"): + self.renderer.render("msbuild.ps1", variables) + + def test_power_shell_quote_is_a_single_literal(self) -> None: + self.assertEqual(ps_quote("plain"), "'plain'") + self.assertEqual(ps_quote("O'Brien"), "'O''Brien'") + self.assertEqual(ps_quote(Path(r"C:\A B\file.txt")), r"'C:\A B\file.txt'") + + def test_msbuild_family_template_stays_reviewable(self) -> None: + meaningful_lines = [ + line + for line in (self.templates / "msbuild.ps1").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + self.assertLessEqual(len(meaningful_lines), 18) + + def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) -> None: + base = self.templates / "catch2-test.ps1" + self.assertTrue(base.is_file(), "Catch2 shard recipes must share one inherited base") + + variables: dict[str, object] = { + "name": "fixture", + "pool": "slot", + "inputs": ["build-tests"], + "pwsh": "pwsh.exe", + "artifacts": [ + {"name": "tests.exe", "source": "C:/cas/tests.exe"}, + {"name": "renpy.so", "source": "C:/cas/renpy.so"}, + ], + "shard_count": 4, + "shard_index": 2, + } + cases = { + "native-test.ps1": ( + variables, + "8e8be23fd290ead07b8a09d6ded0dcf6cd24c56cee6dc8b5f532bb06ad8581d4", + ), + "native-corpus-test.ps1": ( + variables, + "525b016aaf8d444d342412bc5ac81221587d562f65ad895eba88891a2c80685a", + ), + "coverage-test.ps1": ( + variables, + "65e2c48ac05288cf82e4691e3bd3eb5b745fef71dd2ff6212992a706012868d2", + ), + "sanitizer-test.ps1": ( + variables + | { + "runtime": { + "name": "clang_rt.asan_dynamic-x86_64.dll", + "source": "C:/llvm/asan.dll", + }, + "options_name": "ASAN_OPTIONS", + "options_value": "halt_on_error=1", + }, + "6fac2869ac98cb3c3f3fb65f9b1e0856f41a2fabcd3e018f347ddb8e28d1455b", + ), + "sanitizer-test.ps1:ubsan": ( + variables + | { + "runtime": None, + "options_name": "UBSAN_OPTIONS", + "options_value": "halt_on_error=1", + }, + "c885f92dd881d55c2bd017bed98ded28e409cb2c3db1f386869ecfe789618ca6", + ), + } + for case, (values, expected) in cases.items(): + template = case.partition(":")[0] + with self.subTest(case=case): + rendered = self.renderer.render(template, values).encode() + self.assertEqual(hashlib.sha256(rendered).hexdigest(), expected) + + for name in ("native-test.ps1", "coverage-test.ps1", "sanitizer-test.ps1"): + with self.subTest(template=name): + content = (self.templates / name).read_text(encoding="utf-8") + self.assertIn('{% extends "catch2-test.ps1" %}', content) + self.assertLessEqual(len(content.splitlines()), 10) + + def test_template_root_must_be_an_existing_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + file_path = Path(directory) / "template.txt" + file_path.write_text("content", encoding="utf-8") + + with self.assertRaises(NotADirectoryError): + TemplateRenderer(file_path) + with self.assertRaises(FileNotFoundError): + TemplateRenderer(Path(directory) / "missing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_runtime.py b/tools/build/tests/test_runtime.py new file mode 100644 index 0000000..f29448c --- /dev/null +++ b/tools/build/tests/test_runtime.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path +import shutil +import sys +import tempfile +import unittest +from unittest import mock + +from filelock import FileLock, Timeout + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.runtime import BuildRuntime, ProcessFailed # noqa: E402 + + +RUN_ID = "20260801-runtime" + + +def node(name: str = "analyze-renpy.pickle", *, env: tuple[tuple[str, str], ...] = ()) -> Node: + return Node( + name=name, + uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool="cpu", + command=Command( + (r"C:\tools\analyze.exe", "literal argument"), + env=env, + cwd=r"C:\repo\source", + stdin=b"exact recipe\r\n", + ), + ) + + +class FakeRunner: + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.calls: list[tuple[Command, Path]] = [] + + async def run(self, command: Command, *, log) -> int: + self.calls.append((command, Path(log.name))) + log.write(b"runner log") + log.flush() + return self.exit_code + + +class BuildRuntimeTests(unittest.IsolatedAsyncioTestCase): + async def test_lock_uses_persistent_native_async_filelock(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + current = node() + + with mock.patch("core.runtime.AsyncFileLock") as factory: + lock = runtime.lock(current) + + self.assertIs(lock, factory.return_value) + factory.assert_called_once_with( + runtime.paths.lock(current.uid), + timeout=-1, + poll_interval=0.05, + fallback_to_soft=False, + preserve_lock_file=True, + ) + + async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + current = node(env=(("Alpha", "one"),)) + + work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + with ( + mock.patch("core.runtime.shutil.rmtree", wraps=shutil.rmtree) as remove, + mock.patch("asyncio.to_thread", wraps=asyncio.to_thread) as offload, + ): + await runtime.run(current) + + command, log_path = runner.calls[0] + cas = runtime.store.paths_for(current) + self.assertEqual(command.argv, current.command.argv) + self.assertEqual(command.cwd, current.command.cwd) + self.assertEqual(command.stdin, current.command.stdin) + self.assertEqual( + dict(command.env), + { + "Alpha": "one", + "OBSERVER_BUILD_DIR": str(work), + "OBSERVER_OUT_DIR": str(cas.output), + }, + ) + remove.assert_called_once_with(work) + offload.assert_awaited_once_with(remove, work) + self.assertFalse(work.exists()) + self.assertFalse(work.parent.exists()) + self.assertEqual(log_path, cas.log) + self.assertEqual(cas.log.read_bytes(), b"runner log") + self.assertFalse(cas.touch.exists()) + + async def test_run_rejects_case_insensitive_runtime_environment_conflicts(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + + with self.assertRaisesRegex(ValueError, "OBSERVER_OUT_DIR"): + await runtime.run(node(env=(("observer_out_dir", "hostile"),))) + + self.assertEqual(runner.calls, []) + + async def test_executor_runs_and_publishes_success(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + + self.assertFalse(runtime.paths.output_root.exists()) + await runtime.executor(graph).run() + + self.assertEqual(len(runner.calls), 1) + self.assertTrue(runtime.is_complete(current)) + self.assertEqual(runtime.store.paths_for(current).touch.stat().st_size, 0) + + async def test_executor_holds_per_run_lease_without_serializing_distinct_runs(self) -> None: + class ConcurrentRunner: + entered = 0 + both = asyncio.Event() + release = asyncio.Event() + + async def run(self, _command: Command, *, log) -> int: + type(self).entered += 1 + if type(self).entered == 2: + type(self).both.set() + await type(self).release.wait() + return 0 + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + first = BuildRuntime(repository, "run-one", process_runner=ConcurrentRunner()) + second = BuildRuntime(repository, "run-two", process_runner=ConcurrentRunner()) + graphs = tuple( + Graph((current,), (current.name,), {"cpu": 1}) + for current in (node("first"), node("second")) + ) + tasks = tuple( + asyncio.create_task(runtime.executor(graph).run()) + for runtime, graph in zip((first, second), graphs, strict=True) + ) + await asyncio.wait_for(ConcurrentRunner.both.wait(), timeout=2) + + for run_id in ("run-one", "run-two"): + with self.assertRaises(Timeout), FileLock( + first.paths.lease(run_id), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + with FileLock(first.paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + + ConcurrentRunner.release.set() + await asyncio.gather(*tasks) + for run_id in ("run-one", "run-two"): + with FileLock(first.paths.lease(run_id), timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + + async def test_nonzero_exit_leaves_entry_incomplete_without_marker(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner(23)) + runtime.paths.prepare() + current = node() + + with self.assertRaisesRegex(ProcessFailed, "23.*analyze-renpy.pickle"): + await runtime.run(current) + + cas = runtime.store.paths_for(current) + work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + self.assertTrue(cas.entry.is_dir()) + self.assertTrue(work.is_dir()) + self.assertFalse(cas.touch.exists()) + self.assertFalse(runtime.is_complete(current)) + + async def test_cancellation_preserves_scratch(self) -> None: + class CancelledRunner: + async def run(self, _command: Command, *, log) -> int: + raise asyncio.CancelledError + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=CancelledRunner()) + runtime.paths.prepare() + current = node() + work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + + with self.assertRaises(asyncio.CancelledError): + await runtime.run(current) + + self.assertTrue(work.is_dir()) + + async def test_reparse_scratch_is_rejected_instead_of_removed(self) -> None: + reparse = False + + class ReplacingRunner: + async def run(self, _command: Command, *, log) -> int: + nonlocal reparse + reparse = True + return 0 + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=ReplacingRunner()) + runtime.paths.prepare() + current = node() + work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + + with ( + mock.patch("core.paths._is_reparse", side_effect=lambda path: reparse and path == work), + mock.patch("core.runtime.shutil.rmtree") as remove, + self.assertRaisesRegex(ValueError, "reparse point"), + ): + await runtime.run(current) + + remove.assert_not_called() + self.assertTrue(work.is_dir()) + + async def test_quarantine_survives_successful_scratch_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + runtime.paths.prepare() + current = node() + old = runtime.store.paths_for(current) + old.output.mkdir(parents=True) + (old.output / "partial.obj").write_bytes(b"partial") + + await runtime.run(current) + + quarantine = runtime.paths.run_work(RUN_ID) / "quarantine" / old.entry.name + self.assertEqual((quarantine / "out/partial.obj").read_bytes(), b"partial") + self.assertFalse((runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}").exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_sanitizer_graph.py b/tools/build/tests/test_sanitizer_graph.py new file mode 100644 index 0000000..d6c53e3 --- /dev/null +++ b/tools/build/tests/test_sanitizer_graph.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +from contextlib import redirect_stderr +import hashlib +import io +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock +import xml.etree.ElementTree as ET + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from core.sanitizer import SanitizerError, main as sanitizer_main, require_clean_log # noqa: E402 +from graphs.sanitizer import ( # noqa: E402 + AsanRuntime, + SanitizerArtifact, + sanitizer_artifact_graph, + sanitizer_dependency_discovery_slice, + sanitizer_graph, +) +from tests import test_instrumented_graph as instrumented_fixture # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("C:/tools/build.exe",)), + inputs, + ) + + +class SanitizerGraphTests(unittest.TestCase): + def fixture( + self, + root: Path, + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + ) -> tuple[ + Path, Graph, tuple[SanitizerArtifact, ...], tuple[AsanRuntime, ...], Path + ]: + repository = root / "repo" + repository.mkdir() + paths = BuildPaths(repository) + nodes, artifacts = [], [] + for sanitizer, architecture in selections: + restore_name = ( + f"restore-vcpkg-asan-{architecture}" + if sanitizer == "asan" + else f"restore-vcpkg-{architecture}" + ) + restore = node(restore_name) + discovery = node( + f"discover-{sanitizer}-{architecture}", inputs=(restore.name,) + ) + nodes.extend((restore, discovery)) + for name, filename in ( + ("renpy", "renpy.so"), + ("rpgmaker", "rpgmaker.so"), + ("zanzarah", "zanzarah.so"), + ("tests", "tests.exe"), + ): + producer = node( + f"build-{name}-{architecture}-{sanitizer}", + inputs=(discovery.name,), + ) + nodes.append(producer) + artifacts.append( + SanitizerArtifact( + sanitizer, + architecture, + name, + producer, + paths.cas(producer.uid, producer.name).output / filename, + ) + ) + upstream = Graph( + tuple(nodes), tuple(current.name for current in nodes[1:]), {"build": 8} + ) + tools = root / "tools" + tools.mkdir() + pwsh = tools / "pwsh.exe" + pwsh.touch() + runtimes = [] + for architecture, filename in ( + ("x86", "clang_rt.asan_dynamic-i386.dll"), + ("x64", "clang_rt.asan_dynamic-x86_64.dll"), + ): + if ("asan", architecture) in selections: + path = tools / filename + path.touch() + runtimes.append( + AsanRuntime(architecture, path, {"sha256": f"runtime-{architecture}"}) + ) + return repository, upstream, tuple(artifacts), tuple(runtimes), pwsh + + def build(self, root: Path, **options: object) -> Graph: + selections = options.pop( + "selections", (("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")) + ) + repository, upstream, artifacts, runtimes, pwsh = self.fixture( + root, selections # type: ignore[arg-type] + ) + return sanitizer_artifact_graph( + repository, + upstream, + artifacts, + pwsh=pwsh, + pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, + asan_runtimes=runtimes, + **options, + ) + + def test_asan_and_ubsan_build_adapters_create_independent_shard_gates(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build(root, test_shards=2, jobs=6) + paths = BuildPaths(root / "repo") + + self.assertEqual( + graph.pools, + {"build": 8, "sanitizer-shard": 6, "sanitizer-gate": 6}, + ) + self.assertEqual( + graph.targets, + ( + "asan-gate-x64-0", "asan-gate-x64-1", + "asan-gate-x86-0", "asan-gate-x86-1", + "ubsan-gate-x64-0", "ubsan-gate-x64-1", + ), + ) + self.assertEqual(len(graph.nodes), 30) + for sanitizer, architecture in ( + ("asan", "x64"), ("asan", "x86"), ("ubsan", "x64") + ): + builds = tuple( + graph.node(f"build-{name}-{architecture}-{sanitizer}") + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + for index in range(2): + shard = graph.node(f"{sanitizer}-test-{architecture}-{index}") + gate = graph.node(f"{sanitizer}-gate-{architecture}-{index}") + self.assertEqual(shard.inputs, tuple(item.name for item in builds)) + self.assertEqual(gate.inputs, (shard.name,)) + self.assertEqual((shard.pool, gate.pool), ("sanitizer-shard", "sanitizer-gate")) + self.assertEqual( + gate.command.argv[1:], + ( + "-m", "core.sanitizer", "gate", sanitizer, + str(paths.cas(shard.uid, shard.name).log), + ), + ) + script = shard.command.stdin.decode("utf-8") + self.assertIn("Invoke-Checked", script) + self.assertIn("'--shard-count'", script) + self.assertIn("'2'", script) + self.assertIn("'--shard-index'", script) + self.assertIn(f"'{index}'", script) + for binary in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"): + self.assertIn(binary, script) + if sanitizer == "asan": + self.assertIn("ASAN_OPTIONS", script) + self.assertIn("halt_on_error=1:alloc_dealloc_mismatch=1", script) + self.assertIn("clang_rt.asan_dynamic-", script) + self.assertNotIn("UBSAN_OPTIONS", script) + else: + self.assertIn("UBSAN_OPTIONS", script) + self.assertIn("halt_on_error=1:print_stacktrace=1", script) + self.assertNotIn("clang_rt.asan_dynamic-", script) + + def test_runtime_and_pwsh_identities_have_narrow_invalidation_partitions(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, runtimes, pwsh = self.fixture(root) + + def build( + pwsh_version: str = "7.5", runtime_x64: str = "runtime-x64" + ) -> Graph: + changed = tuple( + AsanRuntime( + item.architecture, + item.path, + {"sha256": runtime_x64 if item.architecture == "x64" else "runtime-x86"}, + ) + for item in runtimes + ) + return sanitizer_artifact_graph( + repository, + upstream, + artifacts, + pwsh=pwsh, + pwsh_identity={"version": pwsh_version}, + asan_runtimes=changed, + test_shards=2, + ) + + before = build() + runtime_changed = build(runtime_x64="changed") + pwsh_changed = build(pwsh_version="7.6") + + for current in before.nodes: + if "-test-" not in current.name and "-gate-" not in current.name: + continue + runtime_partition = current.name.startswith("asan-") and "-x64-" in current.name + self.assertEqual( + runtime_partition, + current.uid != runtime_changed.node(current.name).uid, + current.name, + ) + self.assertNotEqual(current.uid, pwsh_changed.node(current.name).uid, current.name) + + def test_adapter_rejects_invalid_sets_outputs_restore_edges_tools_and_pools(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, artifacts, runtimes, pwsh = self.fixture( + root, (("asan", "x64"),) + ) + + def invoke( + selected: tuple[SanitizerArtifact, ...] = artifacts, + *, + source: Graph = upstream, + selected_runtimes: tuple[AsanRuntime, ...] = runtimes, + pwsh_path: Path = pwsh, + **options: object, + ) -> Graph: + return sanitizer_artifact_graph( + repository, + source, + selected, + pwsh=pwsh_path, + pwsh_identity={"version": "7.5"}, + asan_runtimes=selected_runtimes, + **options, + ) + + with self.assertRaisesRegex(ValueError, "at least one"): + invoke(()) + with self.assertRaisesRegex(ValueError, "complete"): + invoke(artifacts[:-1]) + with self.assertRaisesRegex(ValueError, "duplicate"): + invoke(artifacts + (artifacts[0],)) + for invalid in ( + SanitizerArtifact("msan", "x64", "renpy", artifacts[0].producer, artifacts[0].path), + SanitizerArtifact("asan", "arm64", "renpy", artifacts[0].producer, artifacts[0].path), + SanitizerArtifact("ubsan", "x86", "renpy", artifacts[0].producer, artifacts[0].path), + SanitizerArtifact("asan", "x64", "bad", artifacts[0].producer, artifacts[0].path), + ): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "identity"): + invoke((invalid,)) + + wrong_path = SanitizerArtifact( + "asan", "x64", "renpy", artifacts[0].producer, root / "outside.so" + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((wrong_path, *artifacts[1:])) + wrong_name = SanitizerArtifact( + "asan", "x64", "renpy", artifacts[0].producer, + artifacts[0].path.with_name("wrong.so"), + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((wrong_name, *artifacts[1:])) + wrong_producer = SanitizerArtifact( + "asan", "x64", "renpy", artifacts[1].producer, artifacts[1].path + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((wrong_producer, *artifacts[1:])) + impostor = Node( + artifacts[0].producer.name, + hashlib.md5(b"impostor", usedforsecurity=False).hexdigest(), + "build", artifacts[0].producer.command, artifacts[0].producer.inputs, + ) + with self.assertRaisesRegex(ValueError, "producer CAS"): + invoke((SanitizerArtifact("asan", "x64", "renpy", impostor, artifacts[0].path), *artifacts[1:])) + + shared = node("detached-shared") + left = node("detached-left", inputs=(shared.name,)) + right = node("detached-right", inputs=(shared.name,)) + detached = node( + "build-renpy-x64-asan", inputs=(left.name, right.name) + ) + detached_source = Graph( + tuple( + detached if current.name == detached.name else current + for current in upstream.nodes + ) + (shared, left, right), + upstream.targets, + upstream.pools, + ) + detached_artifact = SanitizerArtifact( + "asan", "x64", "renpy", detached, + BuildPaths(repository).cas(detached.uid, detached.name).output / "renpy.so", + ) + with self.assertRaisesRegex(ValueError, "restore ancestor"): + invoke((detached_artifact, *artifacts[1:]), source=detached_source) + + with self.assertRaisesRegex(ValueError, "runtime.*required"): + invoke(selected_runtimes=()) + with self.assertRaisesRegex(ValueError, "duplicate.*runtime"): + invoke(selected_runtimes=runtimes + runtimes) + bad_runtime = AsanRuntime("arm64", runtimes[0].path, {}) + with self.assertRaisesRegex(ValueError, "runtime identity"): + invoke(selected_runtimes=(bad_runtime,)) + bad_name = AsanRuntime("x64", pwsh, {}) + with self.assertRaisesRegex(ValueError, "runtime identity"): + invoke(selected_runtimes=(bad_name,)) + directory_runtime = AsanRuntime("x64", pwsh.parent, {}) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(selected_runtimes=(directory_runtime,)) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(pwsh_path=pwsh.parent) + for options in ({"test_shards": 0}, {"jobs": True}): + with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"): + invoke(**options) + matching = Graph( + upstream.nodes, upstream.targets, + dict(upstream.pools) | {"sanitizer-shard": 4, "sanitizer-gate": 4}, + ) + self.assertEqual(invoke(source=matching).pools["sanitizer-shard"], 4) + conflict = Graph( + upstream.nodes, upstream.targets, + dict(upstream.pools) | {"sanitizer-shard": 1}, + ) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflict) + + def test_complete_graph_discovers_and_builds_all_sanitizer_artifacts_itself(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + llvm_runtime = helper.llvm_runtime(root) + runtime_path = root / "clang_rt.asan_dynamic-x86_64.dll" + runtime_path.touch() + runtimes = ( + AsanRuntime("x64", runtime_path, {"sha256": "asan-x64"}), + ) + selections = (("asan", "x64"), ("ubsan", "x64")) + runtime_identity = {"standalone": "sha256:a", "cxx": "sha256:b"} + discovery = sanitizer_dependency_discovery_slice( + repository, + toolchain, + selections=selections, + llvm_runtime=llvm_runtime, + llvm_runtime_identity=runtime_identity, + jobs=4, + ) + graph = sanitizer_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + selections=selections, + llvm_runtime=llvm_runtime, + llvm_runtime_identity=runtime_identity, + asan_runtimes=runtimes, + jobs=4, + test_shards=1, + ) + + self.assertEqual(graph.targets, ("asan-gate-x64-0", "ubsan-gate-x64-0")) + self.assertEqual( + len(graph.nodes), + len(discovery.nodes) + len(selections) * (4 + 1 + 1), + ) + for sanitizer in ("asan", "ubsan"): + shard = graph.node(f"{sanitizer}-test-x64-0") + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + build = graph.node(f"build-{project}-x64-{sanitizer}") + self.assertIn( + f"'/p:Configuration={'ASan' if sanitizer == 'asan' else 'UBSan'}'", + build.command.stdin.decode(), + ) + self.assertEqual(shard.inputs.count(build.name), 1) + + def test_sanitizer_runtime_is_test_only_and_release_remains_static_mt(self) -> None: + root = ET.parse(BUILD_ROOT.parents[1] / "build/ObserverProject.props").getroot() + namespace = "{http://schemas.microsoft.com/developer/msbuild/2003}" + runtime_libraries = root.findall(f".//{namespace}RuntimeLibrary") + release = next( + item for item in runtime_libraries if "$(Configuration)' == 'Release" in item.get("Condition", "") + ) + self.assertEqual(release.text, "MultiThreaded") + + +class SanitizerGateTests(unittest.TestCase): + def test_clean_logs_pass_and_findings_fail_for_each_runtime(self) -> None: + require_clean_log("asan", "All tests passed (42 assertions)\n") + require_clean_log("ubsan", "All tests passed (42 assertions)\n") + for sanitizer, finding in ( + ("asan", "ERROR: AddressSanitizer: heap-use-after-free"), + ("asan", "AddressSanitizer:DEADLYSIGNAL"), + ("asan", "SUMMARY: AddressSanitizer: double-free"), + ("ubsan", "foo.cpp:3: runtime error: signed integer overflow"), + ("ubsan", "UndefinedBehaviorSanitizer:DEADLYSIGNAL"), + ("ubsan", "SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior"), + ): + with self.subTest(sanitizer=sanitizer, finding=finding), self.assertRaisesRegex( + SanitizerError, "finding" + ): + require_clean_log(sanitizer, finding) + with self.assertRaisesRegex(SanitizerError, "unsupported"): + require_clean_log("msan", "clean") + + def test_cli_has_no_relaxation_switch_and_module_entrypoint(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + log = Path(temporary) / "test.log" + log.write_text("All tests passed\n", encoding="utf-8") + self.assertEqual(sanitizer_main(("gate", "asan", str(log))), 0) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + sanitizer_main(("gate", "asan", str(log), "--allow-findings")) + with ( + mock.patch.object(sys, "argv", ["sanitizer.py", "gate", "ubsan", str(log)]), + self.assertWarnsRegex(RuntimeWarning, "core.sanitizer"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.sanitizer", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_sarif.py b/tools/build/tests/test_sarif.py new file mode 100644 index 0000000..39a43ef --- /dev/null +++ b/tools/build/tests/test_sarif.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.sarif import ( # noqa: E402 + SarifError, + SarifFindingsError, + clang_tidy_to_sarif, + merge_sarif, + normalize_msvc, + require_clean, +) +from core import sarif # noqa: E402 + + +def write_json(path: Path, document: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document), encoding="utf-8") + + +class SarifTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def test_clang_tidy_conversion_is_confined_deduplicated_and_deterministic(self) -> None: + repository = self.root / "repo" + source = repository / "src" / "unit.cpp" + other_source = repository / "src" / "other.cpp" + outside = self.root / "repo-sibling" / "outside.cpp" + for path in (source, other_source, outside): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + logs = self.root / "objects" + first = logs / "z" / "z.ClangTidy.log" + second = logs / "a" / "a.ClangTidy.log" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + duplicate = f"{source}(9,3): warning: duplicate message [modernize-use-nullptr] [renpy.vcxproj]" + first.write_text( + "\n".join( + ( + duplicate, + f"{outside}(1,1): error: outside [outside-check] [renpy.vcxproj]", + f"{source}(1,1): warning: no rule suffix", + f"{source}(2,5): error: second message [-*, bugprone-sizeof-expression] [renpy.vcxproj]", + ) + ), + encoding="utf-8", + ) + second.write_text( + "\n".join( + ( + f"{other_source}(4,2): warning: first message [alpha-check] [renpy.vcxproj]", + duplicate, + f"{source}(3,1): warning: disabled only [-*] [renpy.vcxproj]", + "ordinary compiler output", + ) + ), + encoding="utf-8", + ) + + output = self.root / "reports" / "tidy.sarif" + repeat = self.root / "reports" / "repeat.sarif" + automation_id = "clang-tidy/x64/renpy/pickle/" + clang_tidy_to_sarif(repository, logs, output, automation_id) + clang_tidy_to_sarif(repository, logs, repeat, automation_id) + + self.assertEqual(output.read_bytes(), repeat.read_bytes()) + document = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual("2.1.0", document["version"]) + self.assertEqual(automation_id, document["runs"][0]["automationDetails"]["id"]) + driver = document["runs"][0]["tool"]["driver"] + self.assertEqual( + ["alpha-check", "bugprone-sizeof-expression", "modernize-use-nullptr"], + [rule["id"] for rule in driver["rules"]], + ) + results = document["runs"][0]["results"] + self.assertEqual(3, len(results)) + self.assertEqual( + [ + ("src/other.cpp", 4, "alpha-check", "warning", "first message"), + ("src/unit.cpp", 2, "bugprone-sizeof-expression", "error", "second message"), + ("src/unit.cpp", 9, "modernize-use-nullptr", "warning", "duplicate message"), + ], + [ + ( + result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + result["locations"][0]["physicalLocation"]["region"]["startLine"], + result["ruleId"], + result["level"], + result["message"]["text"], + ) + for result in results + ], + ) + + def test_clang_tidy_missing_log_tree_produces_an_empty_run(self) -> None: + repository = self.root / "repo" + repository.mkdir() + output = self.root / "empty.sarif" + + clang_tidy_to_sarif(repository, self.root / "missing", output, "tidy/exact/") + + run = json.loads(output.read_text(encoding="utf-8"))["runs"][0] + self.assertEqual([], run["results"]) + self.assertEqual([], run["tool"]["driver"]["rules"]) + + def test_msvc_normalization_retains_document_and_assigns_stable_run_ids(self) -> None: + source = self.root / "raw.sarif" + output = self.root / "normalized.sarif" + original = { + "version": "2.1.0", + "$schema": "original-schema", + "inlineExternalProperties": [{"guid": "kept"}], + "runs": [ + {"automationDetails": {"id": "unstable", "description": {"text": "kept"}}, "results": []}, + {"automationDetails": "invalid but replaceable", "properties": {"kept": True}}, + ], + } + write_json(source, original) + + normalize_msvc(source, output, "msvc-analyze/x64/renpy/pickle/") + + normalized = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual("original-schema", normalized["$schema"]) + self.assertEqual(original["inlineExternalProperties"], normalized["inlineExternalProperties"]) + self.assertEqual( + [ + "msvc-analyze/x64/renpy/pickle/run-1/", + "msvc-analyze/x64/renpy/pickle/run-2/", + ], + [run["automationDetails"]["id"] for run in normalized["runs"]], + ) + self.assertEqual({"text": "kept"}, normalized["runs"][0]["automationDetails"]["description"]) + self.assertEqual({"kept": True}, normalized["runs"][1]["properties"]) + + single_source = self.root / "single.sarif" + single_output = self.root / "single-normalized.sarif" + write_json(single_source, {"version": "2.1.0", "runs": [{"results": []}]}) + normalize_msvc(single_source, single_output, "caller-supplied-exact-id") + self.assertEqual( + "caller-supplied-exact-id", + json.loads(single_output.read_text(encoding="utf-8"))["runs"][0]["automationDetails"]["id"], + ) + + def test_normalization_rejects_non_21_documents_or_invalid_runs(self) -> None: + source = self.root / "raw.sarif" + output = self.root / "normalized.sarif" + for document in ( + {"version": "2.0.0", "runs": [{}]}, + {"version": "2.1.0", "runs": []}, + {"version": "2.1.0", "runs": ["not an object"]}, + ): + with self.subTest(document=document): + write_json(source, document) + with self.assertRaises(SarifError): + normalize_msvc(source, output, "id") + + def test_merge_sorts_runs_by_identity_and_is_deterministic(self) -> None: + first = self.root / "first.sarif" + second = self.root / "second.sarif" + write_json(first, {"version": "2.1.0", "runs": [{"automationDetails": {"id": "z/"}, "value": 2}]}) + write_json( + second, + { + "version": "2.1.0", + "runs": [ + {"automationDetails": {"id": "m/"}, "value": 1}, + {"automationDetails": {"id": "a/"}, "value": 0}, + ], + }, + ) + output = self.root / "merged.sarif" + reverse = self.root / "merged-reverse.sarif" + + merge_sarif((first, second), output) + merge_sarif((second, first), reverse) + + self.assertEqual(output.read_bytes(), reverse.read_bytes()) + merged = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(["a/", "m/", "z/"], [run["automationDetails"]["id"] for run in merged["runs"]]) + self.assertEqual([0, 1, 2], [run["value"] for run in merged["runs"]]) + + def test_merge_rejects_missing_or_duplicate_identity_and_no_inputs(self) -> None: + output = self.root / "merged.sarif" + missing = self.root / "missing-id.sarif" + duplicate = self.root / "duplicate.sarif" + write_json(missing, {"version": "2.1.0", "runs": [{"automationDetails": {}}]}) + write_json( + duplicate, + { + "version": "2.1.0", + "runs": [ + {"automationDetails": {"id": "same/"}}, + {"automationDetails": {"id": "same/"}}, + ], + }, + ) + + for inputs in ((), (missing,), (duplicate,)): + with self.subTest(inputs=inputs), self.assertRaises(SarifError): + merge_sarif(inputs, output) + + def test_gate_ignores_non_findings_and_rejects_warnings_and_errors(self) -> None: + clean = self.root / "clean.sarif" + warning = self.root / "warning.sarif" + write_json( + clean, + { + "version": "2.1.0", + "runs": [ + { + "automationDetails": {"id": "clean/"}, + "results": [{"level": "note"}, {"level": "none"}], + } + ], + }, + ) + write_json( + warning, + { + "version": "2.1.0", + "runs": [ + { + "automationDetails": {"id": "findings/"}, + "results": [{"level": "warning"}, {"level": "error"}, {"level": "note"}], + } + ], + }, + ) + + self.assertIsNone(require_clean((clean,))) + with self.assertRaisesRegex(SarifFindingsError, "2 warning/error finding"): + require_clean((clean, warning)) + + def test_cli_dispatches_every_command_into_observer_output_directory(self) -> None: + output = self.root / "out" + output.mkdir() + source = self.root / "raw.sarif" + logs = self.root / "logs" + repository = self.root / "repo" + environment = {"OBSERVER_OUT_DIR": str(output)} + + with mock.patch.dict("os.environ", environment, clear=True): + with mock.patch.object(sarif, "normalize_msvc") as operation: + self.assertEqual( + 0, + sarif.main(("normalize-msvc", str(source), "msvc/id/", "--output-name", "renpy.sarif")), + ) + operation.assert_called_once_with(source, output / "renpy.sarif", "msvc/id/") + + with mock.patch.object(sarif, "clang_tidy_to_sarif") as operation: + self.assertEqual( + 0, + sarif.main(("convert-tidy", str(repository), str(logs), "tidy/id/")), + ) + operation.assert_called_once_with(repository, logs, output / "renpy.sarif", "tidy/id/") + + other = self.root / "other.sarif" + with mock.patch.object(sarif, "merge_sarif") as operation: + self.assertEqual(0, sarif.main(("merge", str(source), str(other)))) + operation.assert_called_once_with([source, other], output / "analysis.sarif") + + with mock.patch.object(sarif, "require_clean") as operation: + self.assertEqual(0, sarif.main(("gate", str(source)))) + operation.assert_called_once_with((source,)) + self.assertEqual([], list(output.iterdir())) + + def test_cli_requires_existing_output_directory_and_confines_output_name(self) -> None: + source = self.root / "raw.sarif" + stderr = io.StringIO() + with mock.patch.dict("os.environ", {}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("gate", str(source))) + self.assertIn("OBSERVER_OUT_DIR", stderr.getvalue()) + + missing = self.root / "missing" + stderr = io.StringIO() + with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(missing)}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("gate", str(source))) + self.assertIn("existing directory", stderr.getvalue()) + + output = self.root / "out" + output.mkdir() + stderr = io.StringIO() + with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(output)}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("normalize-msvc", str(source), "id", "--output-name", "../escape.sarif")) + self.assertIn("confined", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_sign.py b/tools/build/tests/test_sign.py new file mode 100644 index 0000000..00fcedc --- /dev/null +++ b/tools/build/tests/test_sign.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.sign import content_uid # noqa: E402 + + +class ContentUidTests(unittest.TestCase): + def fields(self) -> dict[str, object]: + return { + "recipe": '{"script":"build"}\n', + "inputs": { + "src/archive.cpp": b"archive bytes\x00", + "src/archive.h": b"header bytes\n", + }, + "dependencies": { + "vcpkg": "0123456789abcdef0123456789abcdef", + "headers": "fedcba9876543210fedcba9876543210", + }, + "toolchain": {"msvc": "19.44", "sdk": "10.0.26100.0"}, + "config": {"arch": "x64", "flags": ["/MT", "/W4"]}, + } + + def uid(self, **changes: object) -> str: + fields = self.fields() + fields.update(changes) + return content_uid(**fields) # type: ignore[arg-type] + + def test_uid_is_lowercase_md5(self) -> None: + uid = self.uid() + + self.assertRegex(uid, re.compile(r"^[0-9a-f]{32}$")) + + def test_mapping_order_does_not_change_uid(self) -> None: + fields = self.fields() + + self.assertEqual( + content_uid(**fields), # type: ignore[arg-type] + content_uid( + recipe=fields["recipe"], # type: ignore[arg-type] + inputs=dict(reversed(list(fields["inputs"].items()))), # type: ignore[union-attr] + dependencies=dict( + reversed(list(fields["dependencies"].items())) # type: ignore[union-attr] + ), + toolchain={"sdk": "10.0.26100.0", "msvc": "19.44"}, + config={"flags": ["/MT", "/W4"], "arch": "x64"}, + ), + ) + + def test_every_signed_field_changes_uid(self) -> None: + original = self.uid() + changes = [ + {"recipe": '{"script":"test"}\n'}, + {"inputs": {"src/other.cpp": b"archive bytes\x00", "src/archive.h": b"header bytes\n"}}, + {"inputs": {"src/archive.cpp": b"changed\x00", "src/archive.h": b"header bytes\n"}}, + {"dependencies": {"other": "0123456789abcdef0123456789abcdef", "headers": "fedcba9876543210fedcba9876543210"}}, + {"dependencies": {"vcpkg": "11111111111111111111111111111111", "headers": "fedcba9876543210fedcba9876543210"}}, + {"toolchain": {"compiler": "19.44", "sdk": "10.0.26100.0"}}, + {"toolchain": {"msvc": "19.45", "sdk": "10.0.26100.0"}}, + {"config": {"architecture": "x64", "flags": ["/MT", "/W4"]}}, + {"config": {"arch": "ARM64", "flags": ["/MT", "/W4"]}}, + {"config": {"arch": "x64", "flags": ["/W4", "/MT"]}}, + ] + + for change in changes: + with self.subTest(change=change): + self.assertNotEqual(original, self.uid(**change)) + + def test_binary_inputs_are_framed_without_concatenation_ambiguity(self) -> None: + common = { + "recipe": b"recipe", + "dependencies": {}, + "toolchain": {}, + "config": {}, + } + + self.assertNotEqual( + content_uid(inputs={"a": b"bc"}, **common), + content_uid(inputs={"ab": b"c"}, **common), + ) + + def test_recipe_text_is_signed_as_exact_utf8_bytes(self) -> None: + fields = self.fields() + text_uid = content_uid(**fields) # type: ignore[arg-type] + fields["recipe"] = str(fields["recipe"]).encode("utf-8") + + self.assertEqual(text_uid, content_uid(**fields)) # type: ignore[arg-type] + + def test_noncanonical_inputs_and_dependencies_are_rejected(self) -> None: + valid = self.fields() + invalid_fields = [ + {"recipe": object()}, + {"inputs": {1: b"bytes"}}, + {"inputs": {"source": "text"}}, + {"dependencies": {1: "0123456789abcdef0123456789abcdef"}}, + {"dependencies": {"dep": 1}}, + {"dependencies": {"dep": "not-an-md5"}}, + ] + + for change in invalid_fields: + fields = dict(valid) + fields.update(change) + with self.subTest(change=change), self.assertRaises((TypeError, ValueError)): + content_uid(**fields) # type: ignore[arg-type] + + def test_identity_is_json_and_rejects_ambiguous_values(self) -> None: + valid = self.fields() + valid["toolchain"] = { + "enabled": True, + "generation": 1, + "ratio": 0.5, + "optional": None, + "tuple": ("a", "b"), + } + self.assertRegex(content_uid(**valid), re.compile(r"^[0-9a-f]{32}$")) # type: ignore[arg-type] + + for toolchain in ( + {1: "value"}, + {"nested": {1: "ambiguous"}}, + {"invalid": object()}, + {"nan": float("nan")}, + ): + fields = self.fields() + fields["toolchain"] = toolchain + with self.subTest(toolchain=toolchain), self.assertRaises((TypeError, ValueError)): + content_uid(**fields) # type: ignore[arg-type] + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_source_graph.py b/tools/build/tests/test_source_graph.py new file mode 100644 index 0000000..ed7bcd3 --- /dev/null +++ b/tools/build/tests/test_source_graph.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +import unittest +from dataclasses import replace +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = BUILD_ROOT.parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs.source import SourceTools, _repository_files, source_checks # noqa: E402 +from core.paths import BuildPaths # noqa: E402 + + +class SourceGraphTests(unittest.TestCase): + def tools(self) -> SourceTools: + return SourceTools( + pwsh=Path(r"C:\tools\pwsh.exe"), + clang_format=Path(r"C:\tools\clang-format.exe"), + cppcheck=Path(r"C:\tools\cppcheck.exe"), + psscriptanalyzer=Path(r"C:\modules\PSScriptAnalyzer.psd1"), + vcpkg_root=Path(r"C:\tools\vcpkg"), + environment=(("PATH", r"C:\tools"),), + identity=( + ("clang_format", r"C:\tools\clang-format.exe"), + ("clang_format_version", "21.1"), + ("cppcheck", r"C:\tools\cppcheck.exe"), + ("cppcheck_version", "2.18"), + ("psscriptanalyzer", r"C:\modules\PSScriptAnalyzer.psd1"), + ("psscriptanalyzer_version", "1.24"), + ("pwsh", r"C:\tools\pwsh.exe"), + ("pwsh_version", "7.5"), + ("vcpkg_root", r"C:\tools\vcpkg"), + ), + ) + + def graph(self): + return source_checks(REPOSITORY, self.tools(), jobs=7) + + def test_contract_inventory_excludes_transient_build_state(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + source = repository / "src/file.cpp" + source.parent.mkdir() + source.touch() + for relative in (".coverage", "tools/build/.coverage.agent"): + path = repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + files = _repository_files(repository) + + self.assertEqual(files, (source,)) + + def test_every_independent_source_check_is_a_demand_node(self) -> None: + graph = self.graph() + cpp_sources = sorted( + path + for path in (REPOSITORY / "src").rglob("*") + if path.is_file() and path.suffix in {".cpp", ".h", ".hpp"} + ) + powershell_sources = [REPOSITORY / "build.ps1"] + sorted( + path + for path in (REPOSITORY / "build").rglob("*") + if path.is_file() and path.suffix in {".ps1", ".psm1"} + ) + contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) + + self.assertEqual(len(graph.nodes), 80) + self.assertEqual(graph.targets, ("source-checks",)) + self.assertEqual(dict(graph.pools), {"restore": 1, "slot": 7}) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("format-")]), + len(cpp_sources), + ) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("pssa-")]), + len(powershell_sources), + ) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("contract-")]), + len(contracts), + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("cppcheck-")}, + {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"}, + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")}, + {"restore-vcpkg-x86", "restore-vcpkg-x64", "restore-vcpkg-arm64"}, + ) + for architecture in ("x86", "x64", "arm64"): + self.assertEqual( + graph.node(f"cppcheck-{architecture}").inputs, + (f"restore-vcpkg-{architecture}",), + ) + self.assertTrue( + all( + not node.inputs + for node in graph.nodes + if node.name.startswith(("format-", "pssa-", "contract-")) + ) + ) + + merged = graph.node("merge-source-findings") + pssa_names = { + node.name for node in graph.nodes if node.name.startswith("pssa-") + } + self.assertEqual( + set(merged.inputs), + {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"} | pssa_names, + ) + gate = graph.node("source-checks") + direct = { + node.name + for node in graph.nodes + if node.name.startswith(("format-", "contract-")) + } + self.assertEqual(set(gate.inputs), direct | {"merge-source-findings"}) + + def test_cppcheck_matrix_contains_only_requested_supported_architectures(self) -> None: + graph = source_checks(REPOSITORY, self.tools(), jobs=7, architectures=("arm64", "x64")) + + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("cppcheck-")}, + {"cppcheck-arm64", "cppcheck-x64"}, + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")}, + {"restore-vcpkg-arm64", "restore-vcpkg-x64"}, + ) + self.assertNotIn("cppcheck-x86", graph.node("merge-source-findings").inputs) + for architectures in ((), ("mips",), ("x64", "x64")): + with self.subTest(architectures=architectures), self.assertRaisesRegex(ValueError, "architectures"): + source_checks(REPOSITORY, self.tools(), architectures=architectures) + + def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(self) -> None: + graph = self.graph() + + formatted = graph.node("format-src.modules.renpy.pickle.cpp") + self.assertEqual(formatted.command.argv[0], r"C:\tools\pwsh.exe") + format_script = formatted.command.stdin.decode() + self.assertIn("Invoke-Checked 'C:\\tools\\clang-format.exe'", format_script) + self.assertIn("'--dry-run'", format_script) + self.assertIn("'--Werror'", format_script) + self.assertIn(str(REPOSITORY / "src/modules/renpy/pickle.cpp"), format_script) + + cppcheck = graph.node("cppcheck-x86") + cppcheck_script = cppcheck.command.stdin.decode() + restore = graph.node("restore-vcpkg-x86") + include_dir = ( + BuildPaths(REPOSITORY).cas(restore.uid, restore.name).output + / "observer-x86-windows-static/include" + ) + self.assertIn("Invoke-Checked 'C:\\tools\\cppcheck.exe'", cppcheck_script) + self.assertIn("'--platform=win32W'", cppcheck_script) + self.assertIn("'-D_M_IX86=600'", cppcheck_script) + self.assertIn(f"'-I{include_dir}'", cppcheck_script) + self.assertIn("'--suppress=*:out/cas/*-restore-vcpkg-*/out/*'", cppcheck_script) + self.assertNotIn(".artifacts/vcpkg_installed", cppcheck_script) + self.assertIn('"--output-file=$outDir\\cppcheck.sarif"', cppcheck_script) + self.assertNotIn("--error-exitcode", cppcheck_script) + self.assertIn("Cppcheck did not produce cppcheck.sarif", cppcheck_script) + self.assertIn("'cppcheck/x86/' + \"$index/\"", cppcheck_script) + + pssa = graph.node("pssa-build.ps1") + pssa_script = pssa.command.stdin.decode() + self.assertIn("Import-Module 'C:\\modules\\PSScriptAnalyzer.psd1'", pssa_script) + self.assertIn(str(REPOSITORY / "build.ps1"), pssa_script) + self.assertIn(str(REPOSITORY / "build/PSScriptAnalyzerSettings.psd1"), pssa_script) + self.assertIn('"$outDir\\psscriptanalyzer.sarif"', pssa_script) + self.assertNotIn("PSScriptAnalyzer reported", pssa_script) + self.assertIn("'psscriptanalyzer/build.ps1/'", pssa_script) + + contract = graph.node("contract-build.tests.analysis-reporting.tests.ps1") + self.assertIn( + str(REPOSITORY / "build/tests/analysis-reporting.Tests.ps1"), + contract.command.stdin.decode(), + ) + + merge = graph.node("merge-source-findings") + self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge")) + gate = graph.node("source-checks") + self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate")) + + def test_tool_identity_changes_only_affected_source_check_branch(self) -> None: + before = self.graph() + identity = dict(self.tools().identity) + identity["cppcheck_version"] = "2.19" + after = source_checks( + REPOSITORY, + replace(self.tools(), identity=tuple(identity.items())), + jobs=7, + ) + + self.assertNotEqual( + before.node("cppcheck-x64").uid, + after.node("cppcheck-x64").uid, + ) + for name in ( + "format-src.modules.renpy.pickle.cpp", + "pssa-build.ps1", + "restore-vcpkg-x64", + ): + self.assertEqual(before.node(name).uid, after.node(name).uid) + + identity = dict(self.tools().identity) + identity["vcpkg_root"] = r"C:\new-vcpkg" + moved_restore = source_checks( + REPOSITORY, + replace( + self.tools(), + vcpkg_root=Path(r"C:\new-vcpkg"), + identity=tuple(identity.items()), + ), + jobs=7, + ) + self.assertNotEqual( + before.node("restore-vcpkg-x64").uid, + moved_restore.node("restore-vcpkg-x64").uid, + ) + self.assertNotEqual( + before.node("cppcheck-x64").uid, + moved_restore.node("cppcheck-x64").uid, + ) + for name in ( + "format-src.modules.renpy.pickle.cpp", + "pssa-build.ps1", + ): + self.assertEqual(before.node(name).uid, moved_restore.node(name).uid) + + def test_rendered_powershell_is_parseable(self) -> None: + graph = self.graph() + scripts = "\n".join( + graph.node(name).command.stdin.decode() + for name in ( + "format-src.modules.renpy.pickle.cpp", + "cppcheck-x64", + "pssa-build.ps1", + "contract-build.tests.analysis-reporting.tests.ps1", + ) + ) + parser = """ +$tokens = $null +$errors = $null +[System.Management.Automation.Language.Parser]::ParseInput( + [Console]::In.ReadToEnd(), [ref]$tokens, [ref]$errors) | Out-Null +if ($errors.Count -ne 0) { $errors | Out-String | Write-Error; exit 1 } +""" + result = subprocess.run( + [ + shutil.which("pwsh"), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + parser, + ], + input=scripts, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_source_tools.py b/tools/build/tests/test_source_tools.py new file mode 100644 index 0000000..1a93b56 --- /dev/null +++ b/tools/build/tests/test_source_tools.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from dataclasses import dataclass +from pathlib import Path +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.source_tools import discover_source_tools # noqa: E402 + + +def executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +@dataclass(frozen=True) +class FakeToolchain: + llvm_dir: Path + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + + +class SourceToolDiscoveryTests(unittest.TestCase): + def test_discovers_exact_paths_versions_and_preserves_runtime_environment(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + clang_format = executable(root / "llvm/bin/clang-format.exe") + pwsh = executable(root / "PowerShell/pwsh.exe") + cppcheck = executable(root / "Cppcheck/cppcheck.exe") + pssa = executable(root / "Modules/PSScriptAnalyzer/PSScriptAnalyzer.psd1") + vcpkg_root = root / "vcpkg" + vcpkg_root.mkdir() + toolchain = FakeToolchain( + root / "llvm", + pwsh, + vcpkg_root, + (("LIB", "sdk"),), + ) + responses = ( + "PowerShell 7.5.2", + "clang-format version 21.1.0", + "Cppcheck 2.18.0", + json.dumps({"Path": str(pssa), "Version": "1.24.0"}), + ) + + with ( + mock.patch("core.source_tools.shutil.which", return_value=str(cppcheck)) as which, + mock.patch("core.source_tools._output", side_effect=responses) as output, + ): + tools = discover_source_tools(toolchain) + + self.assertEqual(tools.pwsh, pwsh.resolve()) + self.assertEqual(tools.clang_format, clang_format.resolve()) + self.assertEqual(tools.cppcheck, cppcheck.resolve()) + self.assertEqual(tools.psscriptanalyzer, pssa.resolve()) + self.assertEqual(tools.vcpkg_root, vcpkg_root.resolve()) + self.assertEqual(tools.environment, (("LIB", "sdk"),)) + self.assertEqual( + dict(tools.identity), + { + "clang_format": str(clang_format.resolve()), + "clang_format_version": "clang-format version 21.1.0", + "cppcheck": str(cppcheck.resolve()), + "cppcheck_version": "Cppcheck 2.18.0", + "psscriptanalyzer": str(pssa.resolve()), + "psscriptanalyzer_version": "1.24.0", + "pwsh": str(pwsh.resolve()), + "pwsh_version": "PowerShell 7.5.2", + "vcpkg_root": str(vcpkg_root.resolve()), + }, + ) + which.assert_called_once_with("cppcheck.exe") + self.assertEqual(output.call_count, 4) + self.assertEqual(output.call_args_list[0].args[0], [str(pwsh.resolve()), "--version"]) + self.assertEqual( + output.call_args_list[1].args[0], + [str(clang_format.resolve()), "--version"], + ) + self.assertEqual(output.call_args_list[2].args[0], [str(cppcheck.resolve()), "--version"]) + self.assertEqual(output.call_args_list[3].args[0][0], str(pwsh.resolve())) + self.assertIn("Get-Module -ListAvailable PSScriptAnalyzer", output.call_args_list[3].args[0][-1]) + + def test_missing_cppcheck_is_reported_without_running_commands(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + toolchain = FakeToolchain( + root / "llvm", + executable(root / "pwsh.exe"), + root / "vcpkg", + (), + ) + executable(root / "llvm/bin/clang-format.exe") + (root / "vcpkg").mkdir() + with ( + mock.patch("core.source_tools.shutil.which", return_value=None), + mock.patch("core.source_tools._output") as output, + self.assertRaisesRegex(FileNotFoundError, "cppcheck.exe"), + ): + discover_source_tools(toolchain) + + output.assert_not_called() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_store.py b/tools/build/tests/test_store.py new file mode 100644 index 0000000..9dde552 --- /dev/null +++ b/tools/build/tests/test_store.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Node # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 +from core.store import CasStateError, CasStore # noqa: E402 + + +RUN_ID = "20260801-test" + + +def node(name: str = "analyze-renpy.pickle") -> Node: + return Node( + name=name, + uid=hashlib.md5(name.encode("utf-8"), usedforsecurity=False).hexdigest(), + pool="cpu", + command=Command(("tool",)), + ) + + +class CasStoreTests(unittest.TestCase): + def make_store(self, repository: Path) -> tuple[BuildPaths, CasStore]: + paths = BuildPaths(repository) + paths.prepare() + return paths, CasStore(paths, RUN_ID) + + @staticmethod + def publish_files(paths: BuildPaths, current: Node) -> None: + cas = paths.cas(current.uid, current.name) + cas.entry.mkdir() + cas.output.mkdir() + cas.log.write_text("command succeeded\n", encoding="utf-8") + cas.touch.touch() + + def test_node_maps_to_readable_uid_and_name_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + + cas = store.paths_for(current) + + self.assertEqual( + cas.entry, + paths.cas_root / f"{current.uid}-{current.name}", + ) + + def test_complete_entry_is_a_warm_cache_hit(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + + self.assertTrue(store.is_complete(current)) + + def test_missing_or_malformed_marker_and_missing_outputs_are_cache_misses(self) -> None: + cases = ( + "missing-touch", + "nonempty-touch", + "touch-directory", + "missing-log", + "log-directory", + "missing-output", + "output-file", + ) + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + cas = paths.cas(current.uid, current.name) + + if case == "missing-touch": + cas.touch.unlink() + elif case == "nonempty-touch": + cas.touch.write_bytes(b"not a completion marker") + elif case == "touch-directory": + cas.touch.unlink() + cas.touch.mkdir() + elif case == "missing-log": + cas.log.unlink() + elif case == "log-directory": + cas.log.unlink() + cas.log.mkdir() + elif case == "missing-output": + cas.output.rmdir() + elif case == "output-file": + cas.output.rmdir() + cas.output.touch() + + self.assertFalse(store.is_complete(current)) + + def test_prepare_quarantines_incomplete_entry_inside_current_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + old = paths.cas(current.uid, current.name) + old.entry.mkdir() + old.output.mkdir() + (old.output / "partial.obj").write_bytes(b"partial") + + prepared = store.prepare_entry(current) + + quarantine = paths.run_work(RUN_ID) / "quarantine" / old.entry.name + self.assertEqual(prepared, paths.cas(current.uid, current.name)) + self.assertEqual((quarantine / "out" / "partial.obj").read_bytes(), b"partial") + self.assertTrue(prepared.entry.is_dir()) + self.assertTrue(prepared.output.is_dir()) + self.assertTrue(prepared.log.is_file()) + self.assertFalse(prepared.touch.exists()) + + def test_prepare_resolves_node_paths_once(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + + with mock.patch.object(paths, "cas", wraps=paths.cas) as resolve: + store.prepare_entry(current) + + resolve.assert_called_once_with(current.uid, current.name) + + def test_prepare_never_mutates_complete_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + cas = paths.cas(current.uid, current.name) + sentinel = cas.output / "result.bin" + sentinel.write_bytes(b"immutable") + before = { + child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes()) + for child in (cas.log, cas.touch, sentinel) + } + + prepared = store.prepare_entry(current) + + after = { + child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes()) + for child in (cas.log, cas.touch, sentinel) + } + self.assertEqual(prepared, cas) + self.assertEqual(after, before) + self.assertFalse((paths.run_work(RUN_ID) / "quarantine").exists()) + + def test_prepare_fails_if_incomplete_entry_cannot_be_quarantined(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = paths.cas(current.uid, current.name) + cas.entry.mkdir() + cas.output.mkdir() + destination = paths.run_work(RUN_ID) / "quarantine" / cas.entry.name + destination.mkdir(parents=True) + + with self.assertRaisesRegex(CasStateError, "quarantine destination already exists"): + store.prepare_entry(current) + + self.assertTrue(cas.entry.is_dir()) + self.assertTrue(destination.is_dir()) + + def test_prepare_reports_move_failure_without_deleting_or_retrying(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = paths.cas(current.uid, current.name) + cas.entry.mkdir() + cas.output.mkdir() + + with mock.patch.object(Path, "rename", side_effect=OSError("locked")) as rename: + with self.assertRaisesRegex(CasStateError, "could not quarantine"): + store.prepare_entry(current) + + rename.assert_called_once() + self.assertTrue(cas.entry.is_dir()) + self.assertFalse( + (paths.run_work(RUN_ID) / "quarantine" / cas.entry.name).exists() + ) + + def test_reparse_component_is_rejected_instead_of_followed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + regular = BuildPaths(repository) + regular.prepare() + current = node() + self.publish_files(regular, current) + cas = regular.cas(current.uid, current.name) + + reparse_paths = {cas.output} + + with mock.patch( + "core.paths._is_reparse", + side_effect=lambda path: path in reparse_paths, + ): + store = CasStore(BuildPaths(repository), RUN_ID) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + store.is_complete(current) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + store.prepare_entry(current) + + def test_mark_complete_publishes_zero_byte_marker_after_outputs_exist(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + cas.log.write_text("success\n", encoding="utf-8") + + store.mark_complete(current) + + self.assertTrue(cas.touch.is_file()) + self.assertEqual(cas.touch.stat().st_size, 0) + self.assertTrue(store.is_complete(current)) + + def test_mark_complete_requires_output_and_log_and_never_overwrites_marker(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + cas.log.unlink() + + with self.assertRaisesRegex(CasStateError, "log file"): + store.mark_complete(current) + self.assertFalse(cas.touch.exists()) + + cas.log.write_text("success\n", encoding="utf-8") + cas.touch.write_bytes(b"existing") + original_stat = os.lstat(cas.touch) + + with self.assertRaisesRegex(CasStateError, "completion marker already exists"): + store.mark_complete(current) + + self.assertEqual(cas.touch.read_bytes(), b"existing") + self.assertEqual(os.lstat(cas.touch).st_mtime_ns, original_stat.st_mtime_ns) + + def test_mark_complete_reports_exclusive_publication_race(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + _paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + + with mock.patch.object( + Path, + "open", + autospec=True, + side_effect=FileExistsError("raced"), + ): + with self.assertRaisesRegex(CasStateError, "marker already exists"): + store.mark_complete(current) + + self.assertFalse(cas.touch.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_toolchain.py b/tools/build/tests/test_toolchain.py new file mode 100644 index 0000000..7e3a178 --- /dev/null +++ b/tools/build/tests/test_toolchain.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.toolchain import discover_msvc_toolchain # noqa: E402 + + +def executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +class MsvcToolchainTests(unittest.TestCase): + def test_discovers_x64_tools_and_canonical_command_environment(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + program_files = root / "Program Files (x86)" + system_root = root / "Windows" + installation = root / "Visual Studio" + vcpkg_root = root / "vcpkg" / "2026.07.29" + lib = root / "SDK" / "Lib" + lib.mkdir(parents=True) + vcpkg_root.mkdir(parents=True) + + vswhere = executable( + program_files + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + cmd = executable(system_root / "System32" / "cmd.exe") + msbuild = executable( + installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe" + ) + vsdevcmd = executable(installation / "Common7" / "Tools" / "VsDevCmd.bat") + llvm_dir = installation / "VC" / "Tools" / "Llvm" / "x64" + clang_tidy = executable(llvm_dir / "bin" / "clang-tidy.exe") + pwsh = executable(root / "PowerShell" / "pwsh.exe") + + original = { + "ProgramFiles(x86)": str(program_files), + "SystemRoot": str(system_root), + "VCPKG_ROOT": str(vcpkg_root), + "UNCHANGED": "host", + "Path": "host", + } + command_environment = "\r\n".join( + ( + "=C:=C:\\working", + "Path=first;host", + "PATH=host;second", + "__VSCMD_PREINIT_PATH=host", + f"Lib={lib};{root / 'missing'}", + "VisualStudioVersion=17.0", + "VSCMD_VER=17.14.15", + "VCToolsVersion=14.44.35207", + "WindowsSDKVersion=10.0.26100.0\\", + "UNCHANGED=host", + "", + ) + ) + responses = ( + subprocess.CompletedProcess([], 0, stdout=f"{installation}\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout=command_environment, stderr=""), + subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""), + subprocess.CompletedProcess( + [], + 0, + stdout="LLVM tools\r\n LLVM version 19.1.5\r\nOptimized build.\r\n", + stderr="", + ), + ) + + with ( + mock.patch.dict(os.environ, original, clear=True), + mock.patch("core.toolchain.shutil.which", return_value=str(pwsh)) as which, + mock.patch("core.toolchain.subprocess.run", side_effect=responses) as run, + ): + host_before = dict(os.environ) + toolchain = discover_msvc_toolchain() + host_after = dict(os.environ) + + self.assertEqual(toolchain.installation, installation.resolve()) + self.assertEqual(toolchain.msbuild, msbuild.resolve()) + self.assertEqual(toolchain.vsdevcmd, vsdevcmd.resolve()) + self.assertEqual(toolchain.llvm_dir, llvm_dir.resolve()) + self.assertEqual(toolchain.clang_tidy, clang_tidy.resolve()) + self.assertEqual(toolchain.vcpkg_root, vcpkg_root.resolve()) + self.assertEqual(toolchain.pwsh, pwsh.resolve()) + self.assertEqual( + dict(toolchain.environment), + { + "LIB": str(lib), + "VCToolsVersion": "14.44.35207", + "VisualStudioVersion": "17.0", + "VSCMD_VER": "17.14.15", + "WindowsSDKVersion": "10.0.26100.0\\", + }, + ) + self.assertEqual( + dict(toolchain.identity), + { + "clang_tidy": str(clang_tidy.resolve()), + "clang_tidy_version": "19.1.5", + "installation": str(installation.resolve()), + "msbuild": str(msbuild.resolve()), + "msbuild_version": "17.14.51.32402", + "pwsh": str(pwsh.resolve()), + "vc_tools_version": "14.44.35207", + "vcpkg_root": str(vcpkg_root.resolve()), + "vsdevcmd": str(vsdevcmd.resolve()), + "vsdevcmd_version": "17.14.15", + "windows_sdk_version": "10.0.26100.0\\", + }, + ) + self.assertEqual(host_after, host_before) + self.assertEqual(which.call_args_list, [mock.call("pwsh")]) + self.assertEqual( + run.call_args_list[0].args[0], + [ + str(vswhere.resolve()), + "-latest", + "-products", + "*", + "-requires", + "Microsoft.Component.MSBuild", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.VC.Llvm.Clang", + "-property", + "installationPath", + ], + ) + payload = ( + f'call "{vsdevcmd.resolve()}" -no_logo -arch=amd64 ' + "-host_arch=amd64 >nul && set" + ) + self.assertEqual( + run.call_args_list[1].args[0], + f'"{cmd.resolve()}" /d /s /c "{payload}"', + ) + self.assertEqual( + run.call_args_list[0].kwargs, + {"check": True, "capture_output": True, "text": True}, + ) + self.assertEqual( + run.call_args_list[1].kwargs, + { + "check": True, + "capture_output": True, + "executable": str(cmd.resolve()), + "text": True, + }, + ) + self.assertEqual(run.call_args_list[2].args[0], [str(msbuild.resolve()), "-version", "-nologo"]) + self.assertEqual(run.call_args_list[3].args[0], [str(clang_tidy.resolve()), "--version"]) + for call in (run.call_args_list[2], run.call_args_list[3]): + self.assertEqual( + call.kwargs, + {"check": True, "capture_output": True, "text": True}, + ) + + def test_falls_back_to_bin_msbuild_and_vcpkg_on_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + program_files = root / "Program Files (x86)" + system_root = root / "Windows" + installation = root / "Visual Studio" + executable( + program_files + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + executable(system_root / "System32" / "cmd.exe") + msbuild = executable( + installation / "MSBuild" / "Current" / "Bin" / "MSBuild.exe" + ) + executable(installation / "Common7" / "Tools" / "VsDevCmd.bat") + executable(installation / "VC" / "Tools" / "Llvm" / "x64" / "bin" / "clang-tidy.exe") + pwsh = executable(root / "PowerShell" / "pwsh.exe") + vcpkg = executable(root / "vcpkg" / "2026.07.29" / "vcpkg.exe") + responses = ( + subprocess.CompletedProcess([], 0, stdout=str(installation), stderr=""), + subprocess.CompletedProcess([], 0, stdout="PATH=tools\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout="LLVM version 19.1.5\r\n", stderr=""), + ) + + def which(name: str) -> str: + return str({"pwsh": pwsh, "vcpkg": vcpkg}[name]) + + with ( + mock.patch.dict( + os.environ, + { + "ProgramFiles(x86)": str(program_files), + "SystemRoot": str(system_root), + }, + clear=True, + ), + mock.patch("core.toolchain.shutil.which", side_effect=which) as find, + mock.patch("core.toolchain.subprocess.run", side_effect=responses), + ): + toolchain = discover_msvc_toolchain() + + self.assertEqual(toolchain.msbuild, msbuild.resolve()) + self.assertEqual(toolchain.vcpkg_root, vcpkg.resolve().parent) + self.assertEqual(find.call_args_list, [mock.call("vcpkg"), mock.call("pwsh")]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_vcpkg_template.py b/tools/build/tests/test_vcpkg_template.py new file mode 100644 index 0000000..d652e76 --- /dev/null +++ b/tools/build/tests/test_vcpkg_template.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +from jinja2 import UndefinedError + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.render import TemplateRenderer # noqa: E402 + + +class VcpkgTemplateTests(unittest.TestCase): + def setUp(self) -> None: + self.renderer = TemplateRenderer(BUILD_ROOT / "templates") + self.variables = { + "name": "restore-vcpkg-x64", + "pool": "slot", + "inputs": [], + "pwsh": "pwsh.exe", + "vcpkg": r"C:\tools\vcpkg.exe", + "repository": r"C:\repo with O'Brien", + "triplet": "observer-x64-windows-static", + } + + def test_manifest_restore_targets_node_output_and_requires_include_directory(self) -> None: + recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables)) + script = recipe["script"]["data"] + + self.assertIn("Invoke-Checked 'C:\\tools\\vcpkg.exe' @(", script) + self.assertIn("\n 'install'\n", script) + self.assertIn('\n "--x-install-root=$outDir"\n', script) + self.assertIn("\n '--triplet'\n 'observer-x64-windows-static'\n", script) + self.assertIn("'--x-manifest-root=C:\\repo with O''Brien'", script) + self.assertIn("'--overlay-triplets=C:\\repo with O''Brien\\build\\vcpkg\\triplets'", script) + self.assertIn( + "Test-Path -LiteralPath (Join-Path $outDir " + "'observer-x64-windows-static\\include') -PathType Container", + script, + ) + self.assertIn("throw 'vcpkg restore did not produce the include directory'", script) + + def test_triplet_is_required(self) -> None: + del self.variables["triplet"] + + with self.assertRaisesRegex(UndefinedError, "triplet.*undefined"): + self.renderer.render("vcpkg.ps1", self.variables) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_windows_job.py b/tools/build/tests/test_windows_job.py new file mode 100644 index 0000000..1c1306a --- /dev/null +++ b/tools/build/tests/test_windows_job.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +import win32job + +from core.windows_job import WindowsJob + + +class FakeHandle: + def __init__( + self, + events: list[tuple[object, ...]], + *, + close_error: BaseException | None = None, + ) -> None: + self._events = events + self._close_error = close_error + + def Close(self) -> None: + self._events.append(("close",)) + if self._close_error is not None: + raise self._close_error + + +class FakeWin32Job: + JobObjectExtendedLimitInformation = ( + win32job.JobObjectExtendedLimitInformation + ) + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = ( + win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + + def __init__( + self, + *, + configure_error: BaseException | None = None, + close_error: BaseException | None = None, + ) -> None: + self.events: list[tuple[object, ...]] = [] + self.handle = FakeHandle(self.events, close_error=close_error) + self.configure_error = configure_error + self.information = { + "BasicLimitInformation": {"LimitFlags": 0x40}, + "IoInfo": {}, + } + + def CreateJobObject(self, attributes: object, name: str) -> FakeHandle: + self.events.append(("create", attributes, name)) + return self.handle + + def QueryInformationJobObject( + self, handle: FakeHandle, information_class: int + ) -> dict[str, object]: + self.events.append(("query", handle, information_class)) + return self.information + + def SetInformationJobObject( + self, + handle: FakeHandle, + information_class: int, + information: dict[str, object], + ) -> None: + self.events.append(("set", handle, information_class, information)) + if self.configure_error is not None: + raise self.configure_error + + def AssignProcessToJobObject( + self, handle: FakeHandle, process_handle: int + ) -> None: + self.events.append(("assign", handle, process_handle)) + + def TerminateJobObject(self, handle: FakeHandle, exit_code: int) -> None: + self.events.append(("terminate", handle, exit_code)) + + +class WindowsJobTests(unittest.TestCase): + def job(self, api: FakeWin32Job) -> WindowsJob: + with patch("core.windows_job.win32job", api): + return WindowsJob() + + def test_existing_limits_are_preserved_and_kill_on_close_precedes_assign( + self, + ) -> None: + api = FakeWin32Job() + + with patch("core.windows_job.win32job", api): + job = WindowsJob() + job.assign_process(202) + job.close() + + self.assertEqual( + [event[0] for event in api.events], + ["create", "query", "set", "assign", "close"], + ) + self.assertEqual(api.events[0], ("create", None, "")) + self.assertEqual( + api.information["BasicLimitInformation"][ # type: ignore[index] + "LimitFlags" + ], + 0x40 | win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + ) + + def test_close_is_idempotent(self) -> None: + api = FakeWin32Job() + job = self.job(api) + + with patch("core.windows_job.win32job", api): + job.close() + job.close() + + self.assertEqual(api.events.count(("close",)), 1) + + def test_configuration_failure_closes_handle_and_propagates(self) -> None: + api = FakeWin32Job(configure_error=OSError("configuration failed")) + + with patch("core.windows_job.win32job", api): + with self.assertRaisesRegex(OSError, "configuration failed"): + WindowsJob() + + self.assertEqual( + [event[0] for event in api.events], + ["create", "query", "set", "close"], + ) + + def test_configuration_failure_preserves_close_failure_as_note(self) -> None: + api = FakeWin32Job( + configure_error=OSError("configuration failed"), + close_error=OSError("close failed"), + ) + + with patch("core.windows_job.win32job", api): + with self.assertRaisesRegex(OSError, "configuration failed") as raised: + WindowsJob() + + self.assertTrue( + any("close failed" in note for note in raised.exception.__notes__) + ) + + def test_close_failure_keeps_handle_available_for_tree_termination(self) -> None: + api = FakeWin32Job(close_error=OSError("close failed")) + job = self.job(api) + + with self.assertRaisesRegex(OSError, "close failed"): + job.close() + with patch("core.windows_job.win32job", api): + job.terminate() + + self.assertEqual(api.events.count(("close",)), 1) + self.assertEqual(api.events[-1], ("terminate", api.handle, 1)) + + +@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects") +class WindowsJobIntegrationTests(unittest.TestCase): + def test_real_job_can_be_configured_and_closed(self) -> None: + job = WindowsJob() + job.close() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/tests/test_windows_process.py b/tools/build/tests/test_windows_process.py new file mode 100644 index 0000000..6d9adb2 --- /dev/null +++ b/tools/build/tests/test_windows_process.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import asyncio +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +import psutil + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) +EXE = str(Path(sys.executable).resolve()) + +from core.graph import Command # noqa: E402 +from core.windows_process import WindowsProcessRunner # noqa: E402 + + +class FakeProcess: + def __init__( + self, + events: list[tuple[object, ...]], + *, + resume_error: BaseException | None = None, + communicate_error: BaseException | None = None, + kill_error: BaseException | None = None, + exit_code: int = 0, + ) -> None: + self._handle = 301 + self.returncode: int | None = None + self._events = events + self._resume_error = resume_error + self._communicate_error = communicate_error + self._kill_error = kill_error + self._exit_code = exit_code + self.communicate_started = threading.Event() + self.communicate_release = threading.Event() + self.communicate_release.set() + + def resume(self) -> None: + self._events.append(("resume",)) + if self._resume_error is not None: + raise self._resume_error + + def communicate(self, *, input: bytes) -> tuple[None, None]: + self._events.append(("communicate-start", input)) + self.communicate_started.set() + self.communicate_release.wait(timeout=5) + self._events.append(("communicate-done",)) + if self._communicate_error is not None: + raise self._communicate_error + self.returncode = self._exit_code + return None, None + + def kill(self) -> None: + self._events.append(("kill",)) + self.communicate_release.set() + if self._kill_error is not None: + raise self._kill_error + + def wait(self) -> int: + self._events.append(("wait",)) + self.communicate_release.wait(timeout=5) + self.returncode = self._exit_code + return self._exit_code + + +class FakePopenFactory: + def __init__( + self, + process: FakeProcess, + *, + create_error: BaseException | None = None, + ) -> None: + self._process = process + self._create_error = create_error + self.calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def __call__(self, argv: list[str], **kwargs: object) -> FakeProcess: + self._process._events.append(("popen",)) + self.calls.append((tuple(argv), kwargs)) + if self._create_error is not None: + raise self._create_error + return self._process + + +class FakeJob: + def __init__( + self, + process: FakeProcess, + *, + assign_error: BaseException | None = None, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> None: + self._process = process + self._assign_error = assign_error + self._close_error = close_error + self._terminate_error = terminate_error + self.closed = False + process._events.append(("create-job",)) + + def assign_process(self, process_handle: int) -> None: + self._process._events.append(("assign", process_handle)) + if self._assign_error is not None: + raise self._assign_error + + def close(self) -> None: + if self.closed: + return + self.closed = True + self._process._events.append(("close-job",)) + if self._close_error is not None: + raise self._close_error + self._process.communicate_release.set() + + def terminate(self) -> None: + self._process._events.append(("terminate-job",)) + self._process.communicate_release.set() + if self._terminate_error is not None: + raise self._terminate_error + + +class WindowsProcessRunnerTests(unittest.IsolatedAsyncioTestCase): + def patch_runtime( + self, + process: FakeProcess, + *, + create_error: BaseException | None = None, + assign_error: BaseException | None = None, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> FakePopenFactory: + popen = FakePopenFactory(process, create_error=create_error) + self.enterContext(patch("core.windows_process.psutil.Popen", popen)) + self.enterContext( + patch( + "core.windows_process.WindowsJob", + new=lambda: FakeJob( + process, + assign_error=assign_error, + close_error=close_error, + terminate_error=terminate_error, + ), + ) + ) + return popen + + async def test_literal_argv_is_assigned_before_public_resume(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, exit_code=23) + popen = self.patch_runtime(process) + log = io.BytesIO() + command = Command( + (EXE, "literal ; & | argument", 'quote"inside'), + env=(("ZED", "last"), ("Alpha", "first"), ("PATH", "tools")), + cwd=r"C:\repo\work dir", + stdin=b"Write-Output 'exact'\r\nexit 23\r\n", + ) + + with patch.dict( + os.environ, {"HOST": "base", "Path": "host", "zed": "host"}, clear=True + ): + exit_code = await WindowsProcessRunner().run(command, log=log) + + self.assertEqual(exit_code, 23) + argv, options = popen.calls[0] + self.assertEqual(argv, command.argv) + self.assertIs(options["stdout"], log) + self.assertIs(options["stderr"], subprocess.STDOUT) + self.assertIs(options["stdin"], subprocess.PIPE) + self.assertIs(options["shell"], False) + self.assertIs(options["close_fds"], True) + self.assertEqual(options["executable"], command.argv[0]) + self.assertEqual(options["cwd"], command.cwd) + self.assertEqual( + options["env"], + { + "Alpha": "first", + "HOST": "base", + "PATH": "tools" + os.pathsep + "host", + "ZED": "last", + }, + ) + self.assertEqual(options["creationflags"], 0x00000404) + self.assertLess(events.index(("assign", 301)), events.index(("resume",))) + self.assertLess( + events.index(("resume",)), + events.index(("communicate-start", command.stdin)), + ) + self.assertEqual(events.count(("close-job",)), 1) + self.assertNotIn(("kill",), events) + + async def test_executable_must_be_an_absolute_existing_regular_file(self) -> None: + invalid = ("tool.exe", str(BUILD_ROOT / "missing.exe"), str(BUILD_ROOT)) + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + popen = self.patch_runtime(process) + for executable in invalid: + with self.subTest(executable=executable): + with self.assertRaisesRegex(ValueError, "executable"): + await WindowsProcessRunner().run( + Command((executable,)), log=io.BytesIO() + ) + self.assertEqual(events, []) + self.assertEqual(popen.calls, []) + + async def test_popen_failure_closes_job(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, create_error=OSError("create failed")) + + with self.assertRaisesRegex(OSError, "create failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertEqual(events, [("create-job",), ("popen",), ("close-job",)]) + + async def test_assignment_failure_kills_suspended_process_and_reaps(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, assign_error=OSError("assign failed")) + + with self.assertRaisesRegex(OSError, "assign failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertNotIn(("resume",), events) + self.assertLess(events.index(("close-job",)), events.index(("kill",))) + self.assertIn(("wait",), events) + + async def test_resume_failure_closes_assigned_job_and_reaps(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, resume_error=OSError("resume failed")) + self.patch_runtime(process) + + with self.assertRaisesRegex(OSError, "resume failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess(events.index(("assign", 301)), events.index(("resume",))) + self.assertLess(events.index(("close-job",)), events.index(("wait",))) + self.assertNotIn(("kill",), events) + + async def test_cancellation_closes_job_then_waits_for_communicate(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + process.communicate_release.clear() + self.patch_runtime(process) + task = asyncio.create_task( + WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + ) + await asyncio.to_thread(process.communicate_started.wait, 5) + + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + self.assertLess( + events.index(("close-job",)), events.index(("communicate-done",)) + ) + self.assertNotIn(("kill",), events) + + async def test_job_close_failure_terminates_assigned_job_and_is_noted(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, communicate_error=OSError("write failed")) + self.patch_runtime(process, close_error=OSError("job close failed")) + + with self.assertRaisesRegex(OSError, "write failed") as raised: + await WindowsProcessRunner().run( + Command((EXE,), stdin=b"recipe"), log=io.BytesIO() + ) + + self.assertLess( + events.index(("close-job",)), events.index(("terminate-job",)) + ) + self.assertNotIn(("kill",), events) + self.assertTrue( + any("job close failed" in note for note in raised.exception.__notes__) + ) + + async def test_success_does_not_hide_job_close_failure(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, close_error=OSError("job close failed")) + + with self.assertRaisesRegex(OSError, "job close failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess( + events.index(("close-job",)), events.index(("terminate-job",)) + ) + self.assertNotIn(("kill",), events) + + async def test_kill_failure_is_noted_on_primary_failure(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess( + events, + communicate_error=OSError("write failed"), + kill_error=OSError("kill failed"), + ) + self.patch_runtime( + process, + close_error=OSError("job close failed"), + terminate_error=OSError("job terminate failed"), + ) + + with self.assertRaisesRegex(OSError, "write failed") as raised: + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess(events.index(("close-job",)), events.index(("terminate-job",))) + self.assertLess(events.index(("terminate-job",)), events.index(("kill",))) + notes = raised.exception.__notes__ + self.assertTrue(any("job close failed" in note for note in notes)) + self.assertTrue(any("job terminate failed" in note for note in notes)) + self.assertTrue(any("kill failed" in note for note in notes)) + + +@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects") +class WindowsProcessRunnerIntegrationTests(unittest.IsolatedAsyncioTestCase): + @staticmethod + def command(*arguments: str, stdin: bytes = b"") -> Command: + return Command( + (EXE, *arguments), + env=tuple(os.environ.items()), + cwd=str(BUILD_ROOT), + stdin=stdin, + ) + + async def test_real_process_receives_exact_stdin_and_combines_log(self) -> None: + script = ( + "import sys; data=sys.stdin.buffer.read(); " + "sys.stdout.buffer.write(b'OUT:' + data); " + "sys.stderr.buffer.write(b'|ERR'); raise SystemExit(23)" + ) + payload = b"literal ; & | \x00 recipe\r\n" + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "process.log" + with log_path.open("w+b") as log: + exit_code = await WindowsProcessRunner().run( + self.command("-c", script, stdin=payload), log=log + ) + + self.assertEqual(exit_code, 23) + self.assertEqual(log_path.read_bytes(), b"OUT:" + payload + b"|ERR") + + async def test_real_cancellation_kills_descendant_process(self) -> None: + script = ( + "import subprocess,sys,time; " + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(300)']); " + "print(p.pid, flush=True); time.sleep(300)" + ) + child: psutil.Process | None = None + task: asyncio.Task[int] | None = None + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "tree.log" + try: + with log_path.open("w+b") as log: + task = asyncio.create_task( + WindowsProcessRunner().run( + self.command("-c", script), log=log + ) + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not task.done(): + content = log_path.read_text(encoding="ascii").strip() + if content: + child = psutil.Process(int(content)) + break + await asyncio.sleep(0.05) + if child is None: + if task.done(): + await task + self.fail("child process pid was not reported") + + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + gone, alive = psutil.wait_procs([child], timeout=10) + self.assertEqual(gone, [child]) + self.assertEqual(alive, []) + finally: + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if child is not None and child.is_running(): + child.kill() + child.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/build/uv.lock b/tools/build/uv.lock new file mode 100644 index 0000000..8f37f88 --- /dev/null +++ b/tools/build/uv.lock @@ -0,0 +1,146 @@ +version = 1 +revision = 3 +requires-python = "==3.14.6" + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "observer-build" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "coverage" }, + { name = "filelock" }, + { name = "jinja2" }, + { name = "psutil" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", specifier = "==7.15.2" }, + { name = "filelock", specifier = "==3.32.2" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] From 94bb892c3ac7d17769acb9bc7f470a461a45e136 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 12:05:37 +1000 Subject: [PATCH 04/17] refactor: remove superseded build orchestration --- .github/workflows/main.yml | 620 +------ AGENTS.md | 3 +- README.md | 2 + build/GRAPH.md | 172 -- build/ObserverModules.proj | 131 -- build/build.ps1 | 1575 ----------------- build/dynamic_graph.py | 661 ------- build/graph_driver.py | 1063 ----------- build/graph_profiles.json | 190 -- build/ixdag/__init__.py | 2 - build/ixdag/execute.py | 366 ---- build/ixdag/graph.py | 488 ----- build/ixdag/templates/node.json.j2 | 1 - build/lib/analysis-reporting.ps1 | 179 -- build/lib/common.ps1 | 106 -- build/lib/dynamic-graph-leaves.ps1 | 454 ----- build/lib/graph-leaves.ps1 | 476 ----- build/lib/package-manifest.ps1 | 213 --- build/lib/package-smoke.ps1 | 177 -- build/lib/packaging.ps1 | 118 -- build/lib/verify-routing.ps1 | 95 - build/lib/verify.ps1 | 75 - build/native_graph.py | 458 ----- build/run-msbuild.cmd | 12 - build/tests/analysis-reporting.Tests.ps1 | 123 -- .../tests/build-entrypoint-contract.Tests.ps1 | 295 --- build/tests/ci-reporting-contract.Tests.ps1 | 123 -- build/tests/compiler-analysis-graph.Tests.ps1 | 253 --- build/tests/dynamic-graph-leaves.Tests.ps1 | 193 -- build/tests/graph-leaves.Tests.ps1 | 463 ----- build/tests/leak-release-contract.Tests.ps1 | 86 - build/tests/package-manifest.Tests.ps1 | 291 --- build/tests/package-smoke-contract.Tests.ps1 | 154 -- build/tests/test_dynamic_graph.py | 273 --- build/tests/test_graph_driver.py | 1023 ----------- build/tests/test_ixdag_execute.py | 274 --- build/tests/test_ixdag_graph.py | 189 -- build/tests/test_native_graph.py | 223 --- build/tests/test_verify_graph.py | 127 -- .../verify-orchestration-contract.Tests.ps1 | 98 - build/tests/verify-routing.Tests.ps1 | 65 - build/verify_graph.py | 284 --- docs/README.md | 15 - docs/autonomous-work-log.md | 96 - docs/build-system.md | 132 +- docs/code-deep-dive.md | 197 --- docs/critical-software-methodology.md | 20 +- docs/current-status.md | 178 -- docs/garbro.md | 421 ----- docs/ix-build-adaptation.md | 194 -- docs/quickbms.md | 518 ------ docs/unity.md | 318 ---- src/tests/unit/bounded_stream.cpp | 10 +- tools/build/core/runtime.py | 7 +- tools/build/templates/vcpkg.ps1 | 3 + tools/build/tests/test_msbuild_contracts.py | 70 + tools/build/tests/test_runtime.py | 51 +- tools/build/tests/test_source_graph.py | 27 +- tools/build/tests/test_vcpkg_template.py | 9 + tools/build/tests/test_workflow_contract.py | 45 + 60 files changed, 322 insertions(+), 14163 deletions(-) delete mode 100644 build/GRAPH.md delete mode 100644 build/ObserverModules.proj delete mode 100644 build/build.ps1 delete mode 100644 build/dynamic_graph.py delete mode 100644 build/graph_driver.py delete mode 100644 build/graph_profiles.json delete mode 100644 build/ixdag/__init__.py delete mode 100644 build/ixdag/execute.py delete mode 100644 build/ixdag/graph.py delete mode 100644 build/ixdag/templates/node.json.j2 delete mode 100644 build/lib/analysis-reporting.ps1 delete mode 100644 build/lib/common.ps1 delete mode 100644 build/lib/dynamic-graph-leaves.ps1 delete mode 100644 build/lib/graph-leaves.ps1 delete mode 100644 build/lib/package-manifest.ps1 delete mode 100644 build/lib/package-smoke.ps1 delete mode 100644 build/lib/packaging.ps1 delete mode 100644 build/lib/verify-routing.ps1 delete mode 100644 build/lib/verify.ps1 delete mode 100644 build/native_graph.py delete mode 100644 build/run-msbuild.cmd delete mode 100644 build/tests/analysis-reporting.Tests.ps1 delete mode 100644 build/tests/build-entrypoint-contract.Tests.ps1 delete mode 100644 build/tests/ci-reporting-contract.Tests.ps1 delete mode 100644 build/tests/compiler-analysis-graph.Tests.ps1 delete mode 100644 build/tests/dynamic-graph-leaves.Tests.ps1 delete mode 100644 build/tests/graph-leaves.Tests.ps1 delete mode 100644 build/tests/leak-release-contract.Tests.ps1 delete mode 100644 build/tests/package-manifest.Tests.ps1 delete mode 100644 build/tests/package-smoke-contract.Tests.ps1 delete mode 100644 build/tests/test_dynamic_graph.py delete mode 100644 build/tests/test_graph_driver.py delete mode 100644 build/tests/test_ixdag_execute.py delete mode 100644 build/tests/test_ixdag_graph.py delete mode 100644 build/tests/test_native_graph.py delete mode 100644 build/tests/test_verify_graph.py delete mode 100644 build/tests/verify-orchestration-contract.Tests.ps1 delete mode 100644 build/tests/verify-routing.Tests.ps1 delete mode 100644 build/verify_graph.py delete mode 100644 docs/README.md delete mode 100644 docs/autonomous-work-log.md delete mode 100644 docs/code-deep-dive.md delete mode 100644 docs/current-status.md delete mode 100644 docs/garbro.md delete mode 100644 docs/ix-build-adaptation.md delete mode 100644 docs/quickbms.md delete mode 100644 docs/unity.md create mode 100644 tools/build/tests/test_msbuild_contracts.py create mode 100644 tools/build/tests/test_workflow_contract.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c69789..b15df2a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,8 +5,6 @@ on: branches: [master] pull_request: branches: [master] - schedule: - - cron: "17 18 * * 6" workflow_dispatch: concurrency: @@ -21,178 +19,18 @@ env: VCPKG_DEFAULT_BINARY_CACHE: C:\vcpkg-binary-cache jobs: - source-quality: - name: Source quality + verify: + name: Local verification graph runs-on: windows-2022 - permissions: - contents: read - security-events: write + timeout-minutes: 60 steps: - uses: actions/checkout@v6 - - name: Install source-analysis tools - shell: pwsh - run: | - choco install cppcheck --version=2.19.0 --yes --no-progress - Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force - - - name: Run formatting and source analyzers - id: source_checks - continue-on-error: true - shell: pwsh - run: ./build.ps1 source-checks -Arch all - - - name: Upload Cppcheck x86 SARIF - if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-x86.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/cppcheck/cppcheck-x86.sarif - category: cppcheck/x86 - - - name: Upload Cppcheck x64 SARIF - if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-x64.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/cppcheck/cppcheck-x64.sarif - category: cppcheck/x64 - - - name: Upload Cppcheck ARM64 SARIF - if: always() && hashFiles('.artifacts/reports/cppcheck/cppcheck-arm64.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/cppcheck/cppcheck-arm64.sarif - category: cppcheck/arm64 - - - name: Upload PSScriptAnalyzer SARIF - if: always() && hashFiles('.artifacts/reports/psscriptanalyzer/psscriptanalyzer.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/psscriptanalyzer/psscriptanalyzer.sarif - category: psscriptanalyzer - - - name: Archive source-analysis reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: source-analysis-reports - path: .artifacts/reports - if-no-files-found: ignore - - - name: Enforce source-quality gate - if: steps.source_checks.outcome == 'failure' - shell: pwsh - run: throw 'Source-quality checks failed.' - - tests: - name: MSVC tests (${{ matrix.arch }}, ${{ matrix.config }}) - runs-on: windows-2022 - strategy: - fail-fast: false - matrix: - arch: [x86, x64] - config: [Debug, Release] - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Run deterministic tests - id: tests - continue-on-error: true - shell: pwsh - run: ./build.ps1 test -Arch ${{ matrix.arch }} -Config ${{ matrix.config }} - - - name: Publish test report - if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: dorny/test-reporter@v3 - with: - name: MSVC tests (${{ matrix.arch }}, ${{ matrix.config }}) - path: .artifacts/reports/tests/*.xml - reporter: java-junit - fail-on-error: false - - - name: Archive test reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: test-reports-${{ matrix.arch }}-${{ matrix.config }} - path: .artifacts/reports/tests - if-no-files-found: ignore - - - name: Enforce test gate - if: steps.tests.outcome == 'failure' - shell: pwsh - run: throw 'MSVC tests failed.' - - tests-arm64: - name: MSVC tests (ARM64, ${{ matrix.config }}) - runs-on: windows-11-arm - strategy: - fail-fast: false - matrix: - config: [Debug, Release] - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-arm64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Run native ARM64 deterministic tests - id: tests - continue-on-error: true - shell: pwsh - run: ./build.ps1 test -Arch arm64 -Config ${{ matrix.config }} - - - name: Publish ARM64 test report - if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: dorny/test-reporter@v3 - with: - name: MSVC tests (ARM64, ${{ matrix.config }}) - path: .artifacts/reports/tests/*.xml - reporter: java-junit - fail-on-error: false - - - name: Archive ARM64 test reports - if: always() - uses: actions/upload-artifact@v7 + - name: Set up uv + uses: astral-sh/setup-uv@v8 with: - name: test-reports-arm64-${{ matrix.config }} - path: .artifacts/reports/tests - if-no-files-found: ignore - - - name: Enforce ARM64 test gate - if: steps.tests.outcome == 'failure' - shell: pwsh - run: throw 'Native ARM64 MSVC tests failed.' - - leaks: - name: UMDH leak gate (x64) - runs-on: windows-2022 - permissions: - contents: read - steps: - - uses: actions/checkout@v6 + enable-cache: true + cache-dependency-glob: tools/build/uv.lock - name: Cache vcpkg binaries uses: actions/cache@v5 @@ -200,13 +38,20 @@ jobs: path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - name: Prepare vcpkg cache + - name: Prepare pinned Python environment shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + run: | + New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + uv sync --project tools/build --frozen - - name: Ensure Windows Debugging Tools are available + - name: Provision external verification tools shell: pwsh run: | + choco install cppcheck --version=2.19.0 --yes --no-progress + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force + dotnet tool install --global Microsoft.CodeAnalysis.BinSkim --version 4.4.9.11 + (Join-Path $env:USERPROFILE '.dotnet\tools') | Add-Content -Path $env:GITHUB_PATH + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) $umdh = Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64\umdh.exe' if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { @@ -228,429 +73,14 @@ jobs: throw "UMDH was not found after Windows Debugging Tools setup: $umdh" } - - name: Diagnose leak-test toolchain - shell: pwsh - run: ./build.ps1 doctor -Arch x64 - - - name: Run Release UMDH operation, failure, metadata, and DLL-lifecycle leak tests - id: leaks - continue-on-error: true - shell: pwsh - run: ./build.ps1 test-leaks -Arch x64 - - - name: Archive UMDH leak reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: umdh-leak-reports-x64 - path: .artifacts/reports/leaks/x64 - if-no-files-found: ignore - - - name: Enforce leak gate - if: steps.leaks.outcome == 'failure' - shell: pwsh - run: throw 'UMDH detected sustained heap growth or the leak test failed.' - - coverage: - name: Branch coverage - runs-on: windows-2022 - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Enforce 100 percent source coverage - id: coverage - continue-on-error: true - shell: pwsh - run: ./build.ps1 test-coverage -Arch x64 -CoverageThreshold 100 - - - name: Add coverage summary - if: always() && hashFiles('.artifacts/coverage/x64/coverage.json') != '' - shell: pwsh - run: | - $totals = (Get-Content -Raw .artifacts/coverage/x64/coverage.json | ConvertFrom-Json).data[0].totals - @( - '## LLVM source coverage' - '' - '| Metric | Covered | Total | Percent |' - '|---|---:|---:|---:|' - "| Branches | $($totals.branches.covered) | $($totals.branches.count) | $($totals.branches.percent.ToString('F2'))% |" - "| Functions | $($totals.functions.covered) | $($totals.functions.count) | $($totals.functions.percent.ToString('F2'))% |" - "| Lines | $($totals.lines.covered) | $($totals.lines.count) | $($totals.lines.percent.ToString('F2'))% |" - "| Regions | $($totals.regions.covered) | $($totals.regions.count) | $($totals.regions.percent.ToString('F2'))% |" - ) | Add-Content -Path $env:GITHUB_STEP_SUMMARY - - - name: Publish coverage test report - if: always() && hashFiles('.artifacts/reports/tests/tests-x64-coverage.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: dorny/test-reporter@v3 - with: - name: Coverage tests - path: .artifacts/reports/tests/tests-x64-coverage.xml - reporter: java-junit - fail-on-error: false - - - name: Archive coverage reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-reports - path: | - .artifacts/coverage/x64/coverage.json - .artifacts/coverage/x64/coverage.lcov - .artifacts/reports/tests/tests-x64-coverage.xml - if-no-files-found: ignore - - - name: Enforce coverage gate - if: steps.coverage.outcome == 'failure' - shell: pwsh - run: throw 'Coverage is below the required threshold or the coverage run failed.' - - compiler-analysis: - name: Compiler analysis (${{ matrix.arch }}) - runs-on: windows-2022 - strategy: - fail-fast: false - matrix: - arch: [x86, x64, arm64] - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache + - name: Check prerequisites through the public entry point shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + run: ./build.ps1 doctor - - name: Run MSVC analysis and clang-tidy - id: analysis - continue-on-error: true + # CI repeats the local graph with shorter bounded dynamic-test parameters. + # Gate composition and scheduling belong exclusively to build.ps1. + - name: Run the local verification graph shell: pwsh - run: ./build.ps1 compiler-analysis -Arch ${{ matrix.arch }} - - - name: Upload MSVC SARIF - if: always() && hashFiles('.artifacts/reports/msvc/${{ matrix.arch }}/*.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/msvc/${{ matrix.arch }} - category: msvc-analyze/${{ matrix.arch }} - - - name: Upload clang-tidy SARIF - if: always() && hashFiles('.artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif - category: clang-tidy/${{ matrix.arch }} - - - name: Archive compiler-analysis reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: compiler-analysis-${{ matrix.arch }} - path: | - .artifacts/reports/msvc/${{ matrix.arch }} - .artifacts/reports/clang-tidy/${{ matrix.arch }} - if-no-files-found: ignore - - - name: Enforce compiler-analysis gate - if: steps.analysis.outcome == 'failure' - shell: pwsh - run: throw 'Compiler analysis failed.' - - asan: - name: MSVC ASan (${{ matrix.arch }}) - runs-on: windows-2022 - strategy: - fail-fast: false - matrix: - arch: [x86, x64] - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-asan-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Run MSVC AddressSanitizer tests - id: asan - continue-on-error: true - shell: pwsh - run: ./build.ps1 test-asan -Arch ${{ matrix.arch }} - - - name: Publish ASan test report - if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: dorny/test-reporter@v3 - with: - name: MSVC ASan (${{ matrix.arch }}) - path: .artifacts/reports/tests/*.xml - reporter: java-junit - fail-on-error: false - - - name: Archive ASan reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: asan-reports-${{ matrix.arch }} - path: .artifacts/reports/tests - if-no-files-found: ignore - - - name: Enforce ASan gate - if: steps.asan.outcome == 'failure' - shell: pwsh - run: throw 'MSVC ASan failed.' - - ubsan: - name: clang-cl UBSan - runs-on: windows-2022 - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Run UndefinedBehaviorSanitizer tests - id: ubsan - continue-on-error: true - shell: pwsh - run: ./build.ps1 test-ubsan -Arch x64 - - - name: Publish UBSan test report - if: always() && hashFiles('.artifacts/reports/tests/*.xml') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: dorny/test-reporter@v3 - with: - name: clang-cl UBSan - path: .artifacts/reports/tests/*.xml - reporter: java-junit - fail-on-error: false - - - name: Archive UBSan reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: ubsan-reports - path: .artifacts/reports/tests - if-no-files-found: ignore - - - name: Enforce UBSan gate - if: steps.ubsan.outcome == 'failure' - shell: pwsh - run: throw 'clang-cl UBSan failed.' - - fuzz: - name: Fuzz smoke test - runs-on: windows-2022 - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-x64-asan-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Run libFuzzer - id: fuzz - continue-on-error: true - shell: pwsh - run: | - $secondsPerTarget = if ('${{ github.event_name }}' -eq 'schedule') { 1800 } else { 30 } - ./build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds $secondsPerTarget - - - name: Archive fuzz corpus and crashes - if: always() - uses: actions/upload-artifact@v7 - with: - name: fuzz-results - path: .artifacts/fuzz - if-no-files-found: ignore - - - name: Enforce fuzz gate - if: steps.fuzz.outcome == 'failure' - shell: pwsh - run: throw 'Fuzz smoke test found a failure.' - - codeql: - name: CodeQL C++ - runs-on: windows-2022 - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare dependencies before tracing - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - ./build.ps1 restore -Arch x64 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: c-cpp - build-mode: manual - - - name: Build traced MSVC binaries - shell: pwsh - run: ./build.ps1 build -Arch x64 -Config Release - - - name: Analyze and upload CodeQL SARIF - uses: github/codeql-action/analyze@v4 - with: - category: codeql/c-cpp - output: .artifacts/reports/codeql/raw - post-processed-sarif-path: .artifacts/reports/codeql/uploaded - - - name: Archive CodeQL SARIF - if: always() - uses: actions/upload-artifact@v7 - with: - name: codeql-sarif - path: .artifacts/reports/codeql - if-no-files-found: ignore - - package: - name: Release package (${{ matrix.arch }}) - needs: [tests, tests-arm64, leaks] - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - arch: x86 - runner: windows-2022 - - arch: x64 - runner: windows-2022 - - arch: arm64 - runner: windows-11-arm - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v6 - - - name: Cache vcpkg binaries - uses: actions/cache@v5 - with: - path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - - name: Prepare vcpkg cache - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - - - name: Install BinSkim - shell: pwsh - run: | - dotnet tool install --global Microsoft.CodeAnalysis.BinSkim --version 4.4.9.11 - (Join-Path $env:USERPROFILE '.dotnet\tools') | Add-Content -Path $env:GITHUB_PATH - - - name: Build, audit, and package - id: package - continue-on-error: true - shell: pwsh - run: ./build.ps1 package -Arch ${{ matrix.arch }} - - - name: Upload BinSkim SARIF - if: always() && hashFiles('.artifacts/audit/binskim-${{ matrix.arch }}.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: .artifacts/audit/binskim-${{ matrix.arch }}.sarif - category: binskim/${{ matrix.arch }} - - - name: Archive release packages - if: always() - uses: actions/upload-artifact@v7 - with: - name: packages-${{ matrix.arch }} - path: .artifacts/packages/*.zip - if-no-files-found: error - - - name: Archive binary-audit report - if: always() - uses: actions/upload-artifact@v7 - with: - name: binary-audit-${{ matrix.arch }} - path: .artifacts/audit/binskim-${{ matrix.arch }}.sarif - if-no-files-found: ignore - - - name: Enforce package gate - if: steps.package.outcome == 'failure' - shell: pwsh - run: throw 'Release build, binary audit, or packaging failed.' - - release: - name: Publish release - needs: [source-quality, tests, tests-arm64, leaks, coverage, compiler-analysis, asan, ubsan, fuzz, codeql, package] - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download release packages - uses: actions/download-artifact@v8 - with: - pattern: packages-* - merge-multiple: true - path: release-assets - - - name: Generate release tag - id: tag - shell: bash - run: echo "tag=release-$(date -u +'%Y%m%d-%H%M%S')" >> "$GITHUB_OUTPUT" - - - name: Create GitHub release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ steps.tag.outputs.tag }} - name: Release ${{ steps.tag.outputs.tag }} - body: Automated release from the fully verified master branch. - files: release-assets/*.zip - prerelease: false + run: >- + ./build.ps1 verify -Arch x64 -TestShards 4 -FuzzSeconds 5 + -LeakWarmup 1 -LeakIterations 1 -LeakWindows 3 diff --git a/AGENTS.md b/AGENTS.md index 8459a54..223c525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,5 +89,4 @@ Catch2 tests live in `src/tests/`. Unit tests exercise parser logic directly, wh load the actual module binaries without requiring FAR Manager. Small repository-owned fixtures are mandatory. The external golden corpus is an optional compatibility/stress layer selected with `-Corpus`. -See `docs/build-system.md` for the current command contract and `docs/autonomous-work-log.md` for unresolved decisions -from ongoing build-system work. +See `docs/build-system.md` for the current command contract and build architecture. diff --git a/README.md b/README.md index e4ef561..48e8801 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,14 @@ specific files as needed without having to unpack the entire archive. - Visual Studio Build Tools 2022 with the v143 MSVC tools for x86/x64 and ARM64, Spectre-mitigated libraries, and a Windows 11 SDK - PowerShell 7.4 or newer +- [uv](https://docs.astral.sh/uv/) for the exact-pinned Python build-driver environment - vcpkg available on `PATH` or through `VCPKG_ROOT` No IDE, Visual Studio developer prompt, global vcpkg integration, or repository-level CMake generation is required. From a normal Windows console: ```powershell +uv sync --project tools/build --frozen .\build.ps1 doctor .\build.ps1 build -Arch all -Config Release .\build.ps1 test -Arch x86,x64 -Config Debug diff --git a/build/GRAPH.md b/build/GRAPH.md deleted file mode 100644 index 043e568..0000000 --- a/build/GRAPH.md +++ /dev/null @@ -1,172 +0,0 @@ -# Experimental build graph driver - -> **Historical baseline:** the owner approved the replacement design in -> [`docs/ix-build-adaptation.md`](../docs/ix-build-adaptation.md) on 2026-08-01. -> This coarse driver and the later fine-graph spikes remain only as benchmark/oracle -> code until the new implementation proves parity. Do not extend them into production. - -`graph_driver.py` is a stdlib-only outer DAG experiment. It schedules existing -`build.ps1` commands; it does not replace their MSBuild, vcpkg, analysis, test, or -packaging implementation. Nothing calls the driver from the default build entrypoint. - -MSBuild is the retained native compile/link backend. A direct Python-to-compiler driver -is out of scope; a Ninja-backed prototype would require new build-only evidence showing -a material native critical-path problem before it is considered. - -## Reproducible invocation - -The experiment was verified with uv-managed CPython 3.14.6. This invocation pins the -exact interpreter, disables Python downloads, disables network access, ignores uv -project configuration, and does not install project dependencies: - -```powershell -uv run --no-python-downloads --offline --no-config ` - --cache-dir .artifacts\uv-graph-cache --python 3.14.6 ` - python build\graph_driver.py plan --graph observer-verify-shadow -``` - -If CPython 3.14.6 is not already available to uv, the command fails instead of -installing or updating it. The driver itself has no third-party Python dependencies. -The synthetic profile's `{python}` token expands to the absolute current -`sys.executable`, so child commands neither search `PATH` nor recursively invoke uv. - -Add `--json` for a stable machine-readable plan. Variables are explicit: - -```powershell -uv run --no-python-downloads --offline --no-config ` - --cache-dir .artifacts\uv-graph-cache --python 3.14.6 ` - python build\graph_driver.py plan --graph observer-verify-shadow ` - --set arch=x64 --set fuzz_seconds=60 --json -``` - -`run` is deliberately separate from `plan`; running `observer-verify-shadow` invokes -the real, expensive leaf gates. Per-node output is written below -`.artifacts/graph//logs`, and cache state below the adjacent `state` directory. -Use `--jobs 3` for this profile; higher widths have not been justified on the current -host because each native leaf may already parallelize internally through MSBuild. - -## Execution contract - -- The plan is a deterministic, name-stable topological order. Cycles and unknown - dependencies are rejected before any command starts. -- Commands are JSON argv arrays and execute with `shell=False`, a fixed workspace, - closed stdin, and combined stdout/stderr in a node-specific log. -- A failed node blocks every transitive dependent. Independent ready nodes may finish. -- Each node names one resource pool. Pool capacities and `--jobs` are both enforced - within a driver process. -- A fingerprint includes argv, pool, cache policy, output declarations, explicit - fingerprint tokens, SHA-256 records for matched inputs, and dependency fingerprints. -- A cacheable node is skipped only when its successful state fingerprint matches and - every explicit output still exists. Cacheable nodes without outputs are invalid. -- `cacheable: false` gates always run, even when their fingerprints are unchanged. - -The Observer shadow profile keeps every native gate non-cacheable. It first completes -`doctor`, then one serial `restore -RestoreFlavor all`, and then `source-checks`. The -restore prepares both normal and ASan x64 install roots before any parallel leaf starts; -every later leaf passes `-SkipDependencyRestore`, so concurrent vcpkg processes cannot -race on the manifest lock. The public CLI still restores by default, and a direct -`restore -SkipDependencyRestore` invocation is rejected as a false-success hazard. - -After source checks, the graph fans out into independently safe branches: - -- Debug tests precede compiler analysis because both reuse the Debug object tree. -- Release tests, coverage, and UBSan have configuration-separated object trees and may - overlap the Debug/analysis branch. -- The coarse pilot serializes ASan before fuzzing. The completed output-scope audit found - that this edge is not required: ASan and Fuzz use configuration-separated object and - binary trees, and the restored dependency roots are read-only during both gates. - -The five branch tails join before UMDH, so leak instrumentation never overlaps another -heavy gate, and packaging runs last so its destructive staging cleanup cannot race -another gate. The `normal-x64` pool capacity is three, the ASan pool remains one, and -the recommended global `--jobs 3` caps total external concurrency at three. - -The current full profile defaults to x64 because UBSan and UMDH leak execution are -x64-specific; an all-architecture profile should split build-only ARM64 work from -host-runnable gates instead of merely overriding `arch=all`. - -The profile covers `doctor`, source checks, Debug and Release tests, compiler analysis, -coverage, ASan, UBSan, UMDH leaks, all-format fuzzing, and packaging. Packaging performs -the Release binary audit itself, so a separate `audit-binaries` node would duplicate work. - -## Synthetic cache benchmark - -The checked-in `synthetic-cache-benchmark` profile has one prepare node, two parallel -cacheable branches, and a non-cacheable terminal gate. On the development host on -2026-08-01: - -| Run | Result | Driver time | -| --- | --- | ---: | -| Sequential cold (`--jobs 1`) | all four nodes executed | 0.512 s | -| DAG cold (`--jobs 3`) | compile branches overlapped | 0.383 s | -| DAG warm (`--jobs 3`) | three cache hits; gate still executed | 0.070 s | - -The scheduler supplied a 1.34x cold improvement, and the warm run was 7.31x faster than -the sequential cold baseline. These synthetic results demonstrate concurrency and -cache behavior only; they are not native build performance claims. - -## Real x64 verify benchmark - -The full local x64 gate was measured on the same warm development worktree. Both -successful runs executed every host-capable gate with no deferrals: - -| Run | Result | Wall time | -| --- | --- | ---: | -| Sequential `build.ps1 verify` | green | 1121.262 s | -| Coarse DAG (`--jobs 3`) | green | 834.592 s | - -The coarse graph saved 286.670 seconds (25.6%, 1.34x). Its single -`compiler-analysis` leaf still took 673.687 seconds, while all-format fuzzing took -253.546 seconds and the leak gate took 92.583 seconds. Those timings establish the next -step: replace monolithic gate leaves with project, translation-unit, fuzz-target, -leak-scenario, audit-tool, and package-unit fan-out/fan-in. The measured coarse profile -is a baseline, not the target architecture. - -## Size and replacement projection - -The implementation was reduced from 756 to 533 production lines after review. Its -remaining groups are approximately: 116 lines of models/path validation, 195 lines of -graph validation/topological planning/content fingerprints, 129 lines of safe process -execution/scheduling/cache/log handling, and 93 lines of JSON expansion plus CLI. -Cycle diagnostics, failure propagation, path confinement, and `shell=False` execution -were retained rather than compressed away. - -This pilot does **not** currently reduce repository LOC: it adds 533 driver lines plus -the profile while replacing none of the Windows leaf implementation or the default -entrypoint. The decomposed implementation currently has a 1,531-line internal entrypoint -and 2,321 PowerShell production lines including its root forwarder and `build/lib` files. -Replacing only `verify.ps1` and `verify-routing.ps1` with this outer scheduler would add -roughly 550 net production/config lines, so adoption on LOC grounds would fail. - -A full replacement would still need Visual Studio discovery, MSBuild/vcpkg execution, -coverage/sanitizer/UMDH/audit/package implementations, and their tests. The pilot has not -ported enough of that surface to support a credible full-replacement LOC estimate. Native -timing and a bounded leaf-port spike must justify any further migration. - -## Do the recipes need templating? - -Not yet. The observed repetition is limited to the `pwsh ... build.ps1` argv prefix, -shared input glob sets, and similar gate records. If this profile grows, schema-native -command prefixes, named input sets, and node defaults would remove those repetitions -while preserving reviewable normalized JSON. - -Dependencies, pool selection, cacheability, outputs, and security-sensitive gate flags -should remain explicit. Jinja would make the executable graph harder to review and add -another runtime. Only a much larger architecture/configuration matrix would justify -generation; even then, prefer a typed stdlib Python generator that emits and validates -normalized JSON over a text-template language. - -## Experimental limitations - -Pool limits and cache state are process-local; concurrent driver processes are not -locked against each other. The cache is local and only as complete as each node's -declared inputs and outputs. Environment variables are inherited. Production adoption -would additionally require cancellation policy, interprocess locking, complete graph -profiles for x86/x64/ARM64 routing, and the same source-quality coverage expected of -the existing build scripts. - -Accordingly the coarse pilot proves useful scheduling but is not yet production-ready. -Its full x64 run is green and materially faster, while the measurements show that -monolithic analysis and dynamic leaves still hide most available parallelism. -`build.ps1` remains the correct default until the fine-grained graph has equivalent -local evidence for the supported architecture matrix. diff --git a/build/ObserverModules.proj b/build/ObserverModules.proj deleted file mode 100644 index cb2c6bc..0000000 --- a/build/ObserverModules.proj +++ /dev/null @@ -1,131 +0,0 @@ - - - - Debug - x64 - x86 - x64 - arm64 - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\.artifacts\reports\msvc\')) - $([System.IO.Path]::GetFullPath('$(ObserverAnalysisReportDirectory)\')) - $([System.IO.Path]::GetFullPath('$(AnalysisRequestedReportRoot)$(AnalysisPlatformMoniker)\')) - Configuration=$(Configuration);Platform=$(Platform);VcpkgRoot=$(VcpkgRoot);ObserverRunCodeAnalysis=$(ObserverRunCodeAnalysis);ObserverAnalysisReportDirectory=$(ObserverAnalysisReportDirectory);ObserverEnableClangTidy=$(ObserverEnableClangTidy);LLVMInstallDir=$(LLVMInstallDir);LLVMRuntimeDir=$(LLVMRuntimeDir) - - - - - - - - - - - - - renpy - Debug - Win32;x64;ARM64 - - - rpgmaker - Debug - Win32;x64;ARM64 - - - zanzarah - Debug - Win32;x64;ARM64 - - - tests - Debug - Win32;x64;ARM64 - - - fuzz-pickle - Debug - Win32;x64;ARM64 - - - fuzz-renpy - Debug - Win32;x64;ARM64 - - - fuzz-rpgmaker - Debug - Win32;x64;ARM64 - - - fuzz-zanzarah - Debug - Win32;x64;ARM64 - - - leak-probe - Release - x64 - - - - - - - - - - - - - - - - - - - - - - - - - $([System.IO.File]::GetAttributes('%(AnalysisReportPathToValidate.FullPath)')) - - - - - - - - - - - - - - - - - - - - - diff --git a/build/build.ps1 b/build/build.ps1 deleted file mode 100644 index ec23e38..0000000 --- a/build/build.ps1 +++ /dev/null @@ -1,1575 +0,0 @@ -#requires -Version 7.4 - -[CmdletBinding()] -param( - [Parameter(Position = 0)] - [ValidateSet( - 'help', - 'doctor', - 'restore', - 'build', - 'test', - 'source-checks', - 'compiler-analysis', - 'test-coverage', - 'test-asan', - 'test-ubsan', - 'test-leaks', - 'fuzz', - 'audit-binaries', - 'package', - 'verify', - 'clean' - )] - [string] $Command = 'help', - - [string[]] $Arch = @('x64'), - - [ValidateSet('Debug', 'Release')] - [string] $Config = 'Debug', - - [string] $Corpus, - - [ValidateSet('default', 'asan', 'all')] - [string] $RestoreFlavor = 'default', - - [switch] $SkipDependencyRestore, - - [ValidateRange(1, 86400)] - [int] $FuzzSeconds = 60, - - [ValidateSet('all', 'pickle', 'renpy', 'rpgmaker', 'zanzarah')] - [string] $FuzzTarget = 'all', - - [ValidateRange(1, 1000000)] - [int] $LeakWarmup = 8, - - [ValidateRange(1, 1000000)] - [int] $LeakIterations = 100, - - [ValidateRange(3, 10)] - [int] $LeakWindows = 3, - - [ValidateRange(0, 1073741824)] - [int64] $LeakToleranceBytes = 0, - - [ValidateRange(0, 100)] - [double] $CoverageThreshold = 100 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$script:BuildRoot = $PSScriptRoot -$script:RepositoryRoot = Split-Path $script:BuildRoot -Parent -$script:AggregateProject = Join-Path $script:BuildRoot 'ObserverModules.proj' -$script:ArtifactsRoot = Join-Path $script:RepositoryRoot '.artifacts' -$script:KnownArchitectures = @('x86', 'x64', 'arm64') -$script:ModuleNames = @('renpy', 'rpgmaker', 'zanzarah') - -. (Join-Path $script:BuildRoot 'lib\package-manifest.ps1') -. (Join-Path $script:BuildRoot 'lib\analysis-reporting.ps1') -. (Join-Path $script:BuildRoot 'lib\common.ps1') -. (Join-Path $script:BuildRoot 'lib\package-smoke.ps1') -. (Join-Path $script:BuildRoot 'lib\verify-routing.ps1') -. (Join-Path $script:BuildRoot 'lib\packaging.ps1') -. (Join-Path $script:BuildRoot 'lib\verify.ps1') - -function Resolve-VisualStudio { - $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) - $vswhere = Join-Path $programFilesX86 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path -LiteralPath $vswhere)) { - throw 'vswhere.exe was not found. Install Visual Studio 2022 Build Tools with the Desktop development with C++ workload.' - } - - $installationPath = @( - & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -property installationPath - ) | Select-Object -First 1 - if (-not $installationPath) { - throw 'Visual Studio Build Tools with MSBuild were not found.' - } - - $msbuild = Join-Path $installationPath 'MSBuild\Current\Bin\amd64\MSBuild.exe' - if (-not (Test-Path -LiteralPath $msbuild)) { - $msbuild = Join-Path $installationPath 'MSBuild\Current\Bin\MSBuild.exe' - } - if (-not (Test-Path -LiteralPath $msbuild)) { - throw "MSBuild.exe was not found under '$installationPath'." - } - - $toolsetDirectory = Get-ChildItem -Directory -LiteralPath (Join-Path $installationPath 'VC\Tools\MSVC') | - Sort-Object { [version]$_.Name } -Descending | - Select-Object -First 1 - if (-not $toolsetDirectory) { - throw "No MSVC toolset was found under '$installationPath'." - } - - $dumpbin = Join-Path $toolsetDirectory.FullName 'bin\Hostx64\x64\dumpbin.exe' - if (-not (Test-Path -LiteralPath $dumpbin)) { - throw "dumpbin.exe was not found under '$($toolsetDirectory.FullName)'." - } - - $developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' - if (-not (Test-Path -LiteralPath $developerCommand)) { - throw "VsDevCmd.bat was not found under '$installationPath'." - } - - return [pscustomobject]@{ - InstallationPath = $installationPath.ToString() - MSBuild = $msbuild - ToolsetDirectory = $toolsetDirectory.FullName - ToolsetVersion = $toolsetDirectory.Name - Dumpbin = $dumpbin - DeveloperCommand = $developerCommand - Vswhere = $vswhere - } -} - -function Initialize-MSVCEnvironment { - param( - [Parameter(Mandatory)] $VisualStudio, - [Parameter(Mandatory)][string] $Architecture - ) - - $developerArchitecture = switch ($Architecture) { - 'x86' { 'x86' } - 'x64' { 'amd64' } - 'arm64' { 'arm64' } - default { throw "Unsupported architecture '$Architecture'." } - } - $commandProcessor = [Environment]::GetEnvironmentVariable('ComSpec', 'Process') - if (-not $commandProcessor) { - $commandProcessor = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'cmd.exe' - } - $commandLine = 'call "{0}" -no_logo -arch={1} -host_arch=amd64 >nul && set' -f $VisualStudio.DeveloperCommand, $developerArchitecture - $environmentLines = @(& $commandProcessor /d /s /c $commandLine) - if ($LASTEXITCODE -ne 0) { - throw "VsDevCmd failed for architecture '$Architecture'." - } - foreach ($line in $environmentLines) { - if ($line -match '^([^=]+)=(.*)$') { - [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') - } - } - - # Some process launchers inject both PATH and Path. .NET Framework build tasks reject that environment block. - $cleanPath = [Environment]::GetEnvironmentVariable('PATH', 'Process') - [Environment]::SetEnvironmentVariable('PATH', $null, 'Process') - [Environment]::SetEnvironmentVariable('Path', $null, 'Process') - [Environment]::SetEnvironmentVariable('PATH', $cleanPath, 'Process') - - # VsDevCmd includes optional ATL/MFC directories even when that workload is absent. - # Roslyn-backed MSBuild tasks warn about every nonexistent LIB entry, so retain only real paths. - $cleanLibraryPath = @( - [Environment]::GetEnvironmentVariable('LIB', 'Process') -split ';' | - Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) } - ) -join ';' - [Environment]::SetEnvironmentVariable('LIB', $null, 'Process') - [Environment]::SetEnvironmentVariable('Lib', $null, 'Process') - [Environment]::SetEnvironmentVariable('LIB', $cleanLibraryPath, 'Process') -} - -function Resolve-Vcpkg { - $candidates = [System.Collections.Generic.List[string]]::new() - - $environmentRoot = [Environment]::GetEnvironmentVariable('VCPKG_ROOT', 'Process') - if ($environmentRoot) { - $candidates.Add($environmentRoot) - } - - $vcpkgCommand = Get-Command vcpkg.exe -ErrorAction SilentlyContinue - if ($vcpkgCommand) { - $commandDirectory = Split-Path $vcpkgCommand.Source -Parent - $candidates.Add($commandDirectory) - $candidates.Add((Join-Path (Split-Path $commandDirectory -Parent) 'apps\vcpkg\current')) - } - - $scoopCommand = Get-Command scoop -ErrorAction SilentlyContinue - if ($scoopCommand) { - try { - $scoopPrefix = @(& $scoopCommand.Source prefix vcpkg 2>$null) | Select-Object -First 1 - if ($LASTEXITCODE -eq 0 -and $scoopPrefix) { - $candidates.Add($scoopPrefix.ToString()) - } - } catch { - Write-Verbose "Scoop prefix lookup failed; direct vcpkg candidates remain available: $($_.Exception.Message)" - } - } - - foreach ($candidate in $candidates) { - if (-not $candidate) { - continue - } - $root = [System.IO.Path]::GetFullPath($candidate) - $executable = Join-Path $root 'vcpkg.exe' - $props = Join-Path $root 'scripts\buildsystems\msbuild\vcpkg.props' - if ((Test-Path -LiteralPath $executable) -and (Test-Path -LiteralPath $props)) { - return [pscustomobject]@{ Root = $root; Executable = $executable } - } - } - - throw 'A complete vcpkg installation was not found. Set VCPKG_ROOT or install vcpkg with Scoop.' -} - -function Resolve-Llvm { - $candidateDirectories = [System.Collections.Generic.List[string]]::new() - $tidyCommand = Get-Command clang-tidy.exe -ErrorAction SilentlyContinue - if ($tidyCommand) { - $candidateDirectories.Add((Split-Path $tidyCommand.Source -Parent)) - } - - try { - $visualStudio = Resolve-VisualStudio - $candidateDirectories.Add((Join-Path $visualStudio.InstallationPath 'VC\Tools\Llvm\x64\bin')) - $candidateDirectories.Add((Join-Path $visualStudio.InstallationPath 'VC\Tools\Llvm\bin')) - } - catch { - Write-Verbose "Visual Studio LLVM lookup failed; a standalone LLVM installation may still be available: $($_.Exception.Message)" - } - - foreach ($binDirectory in $candidateDirectories | Select-Object -Unique) { - $tools = @{ - Clang = Join-Path $binDirectory 'clang-cl.exe' - Cov = Join-Path $binDirectory 'llvm-cov.exe' - Format = Join-Path $binDirectory 'clang-format.exe' - Profdata = Join-Path $binDirectory 'llvm-profdata.exe' - Tidy = Join-Path $binDirectory 'clang-tidy.exe' - } - if (@($tools.Values | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count -eq 0) { - return [pscustomobject]@{ - Root = Split-Path $binDirectory -Parent - Bin = $binDirectory - Clang = $tools.Clang - Cov = $tools.Cov - Format = $tools.Format - Profdata = $tools.Profdata - Tidy = $tools.Tidy - } - } - } - - throw 'A complete LLVM toolset (clang-cl, clang-format, clang-tidy, llvm-cov, llvm-profdata) was not found.' -} - -function Resolve-Cppcheck { - $command = Get-Command cppcheck.exe -ErrorAction SilentlyContinue - if (-not $command) { - throw 'cppcheck.exe was not found. Install Cppcheck and ensure it is available on PATH.' - } - return $command.Source -} - -function Resolve-BinSkim { - $command = Get-Command BinSkim.exe -ErrorAction SilentlyContinue - if (-not $command) { - throw 'BinSkim.exe was not found. Install Microsoft.CodeAnalysis.BinSkim and ensure it is available on PATH.' - } - return $command.Source -} - -function Resolve-WindowsDebuggingTool { - $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) - $candidates = @( - Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64' - Join-Path $programFilesX86 'Windows Kits\11\Debuggers\x64' - ) - - foreach ($directory in $candidates) { - $umdh = Join-Path $directory 'umdh.exe' - if (Test-Path -LiteralPath $umdh -PathType Leaf) { - return [pscustomobject]@{ - Directory = $directory - Umdh = $umdh - } - } - } - - throw 'UMDH and gflags.exe were not found. Enable Debugging Tools for Windows in the installed Windows SDK.' -} - -function Invoke-MSBuild { - param( - [Parameter(Mandatory)][string] $Target, - [Parameter(Mandatory)][string] $Architecture, - [Parameter(Mandatory)][string] $Configuration, - [Parameter()][hashtable] $Properties = @{} - ) - - $visualStudio = Resolve-VisualStudio - Initialize-MSVCEnvironment -VisualStudio $visualStudio -Architecture $Architecture - $vcpkg = Resolve-Vcpkg - $platform = Get-MSBuildPlatform $Architecture - $arguments = [System.Collections.Generic.List[string]]::new() - $arguments.Add($script:AggregateProject) - $arguments.Add('/nologo') - $arguments.Add('/m') - $arguments.Add('/nr:false') - $msbuildVerbosity = if ([string]::IsNullOrWhiteSpace($env:OBSERVER_MSBUILD_VERBOSITY)) { - 'minimal' - } - else { - $env:OBSERVER_MSBUILD_VERBOSITY - } - $arguments.Add("/verbosity:$msbuildVerbosity") - $arguments.Add("/t:$Target") - $arguments.Add("/p:Configuration=$Configuration") - $arguments.Add("/p:Platform=$platform") - $arguments.Add("/p:VcpkgRoot=$($vcpkg.Root)") - foreach ($entry in $Properties.GetEnumerator()) { - $arguments.Add("/p:$($entry.Key)=$($entry.Value)") - } - - $previousMSBuild = [Environment]::GetEnvironmentVariable('OBSERVER_MSBUILD_EXE', 'Process') - try { - [Environment]::SetEnvironmentVariable('OBSERVER_MSBUILD_EXE', $visualStudio.MSBuild, 'Process') - Invoke-Native -FilePath (Join-Path $script:BuildRoot 'run-msbuild.cmd') -Arguments $arguments.ToArray() - } finally { - [Environment]::SetEnvironmentVariable('OBSERVER_MSBUILD_EXE', $previousMSBuild, 'Process') - } -} - -function Invoke-Restore { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter()][ValidateSet('default', 'asan', 'all')][string] $Flavor = 'default' - ) - - if ($SkipDependencyRestore) { - Write-Verbose 'Skipping dependency restore because -SkipDependencyRestore was explicitly requested.' - return - } - - $vcpkg = Resolve-Vcpkg - $overlayTriplets = Join-Path $script:BuildRoot 'vcpkg\triplets' - $requestedFlavors = if ($Flavor -eq 'all') { @('default', 'asan') } else { @($Flavor) } - - foreach ($requestedFlavor in $requestedFlavors) { - foreach ($architecture in $Architectures) { - if ($requestedFlavor -eq 'asan' -and $architecture -eq 'arm64') { - Write-Verbose 'Skipping the unsupported ARM64 ASan dependency flavor.' - continue - } - - $triplet = Get-VcpkgTriplet -Architecture $architecture -Flavor $requestedFlavor - $installRootSuffix = if ($requestedFlavor -eq 'asan') { "$architecture-asan" } else { $architecture } - $installRoot = Join-Path $script:ArtifactsRoot "vcpkg_installed\$installRootSuffix" - New-Item -ItemType Directory -Force -Path $installRoot | Out-Null - Write-Step "Restoring vcpkg dependencies for $architecture ($triplet)" - Invoke-Native -FilePath $vcpkg.Executable -Arguments @( - 'install', - "--triplet=$triplet", - "--overlay-triplets=$overlayTriplets", - "--x-manifest-root=$script:RepositoryRoot", - "--x-install-root=$installRoot" - ) - } - } -} - -function Invoke-Build { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter(Mandatory)][string] $Configuration, - [Parameter()][string] $Target = 'Build', - [Parameter()][hashtable] $Properties = @{} - ) - - foreach ($architecture in $Architectures) { - Write-Step "Building $Target for $architecture $Configuration" - Invoke-MSBuild -Target $Target -Architecture $architecture -Configuration $Configuration -Properties $Properties - } -} - -function Test-CanRunArchitecture { - param([Parameter(Mandatory)][string] $Architecture) - - return Test-VerifyArchitectureRunnable ` - -HostArchitecture (Get-CurrentVerifyHostArchitecture) ` - -TargetArchitecture $Architecture -} - -function Get-TestReportPath { - param( - [Parameter(Mandatory)][string] $Architecture, - [Parameter(Mandatory)][string] $Configuration, - [Parameter()][string] $Suite = 'tests' - ) - - $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\tests' - New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null - return Join-Path $reportDirectory "$Suite-$Architecture-$($Configuration.ToLowerInvariant()).xml" -} - -function Invoke-TestExecutable { - param( - [Parameter(Mandatory)][string] $Architecture, - [Parameter(Mandatory)][string] $Configuration, - [Parameter()][string] $CorpusPath - ) - - if (-not (Test-CanRunArchitecture $Architecture)) { - throw "The current host cannot execute $Architecture test binaries. Use a native $Architecture CI runner." - } - - $binaryDirectory = Get-BinaryDirectory -Architecture $Architecture -Configuration $Configuration - $testExecutable = Join-Path $binaryDirectory 'tests.exe' - if (-not (Test-Path -LiteralPath $testExecutable)) { - throw "Test executable was not found: $testExecutable" - } - - $previousCorpus = [Environment]::GetEnvironmentVariable('OBSERVER_TEST_CORPUS', 'Process') - try { - if ($CorpusPath) { - $resolvedCorpus = Resolve-UserPath -Path $CorpusPath - if (-not (Test-Path -LiteralPath $resolvedCorpus -PathType Container)) { - throw "Corpus directory does not exist: $resolvedCorpus" - } - [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $resolvedCorpus, 'Process') - } - - Write-Step "Running tests for $Architecture $Configuration" - $testReportPath = Get-TestReportPath -Architecture $Architecture -Configuration $Configuration - Invoke-Native -FilePath $testExecutable -Arguments @( - '--reporter', 'compact', - '--reporter', "JUnit::out=$testReportPath", - '--durations', 'yes', - '--order', 'lex' - ) -WorkingDirectory $binaryDirectory - Write-Host "Report: $testReportPath" - if ($CorpusPath) { - Write-Step "Running external compatibility corpus for $Architecture $Configuration" - $compatibilityReportPath = Get-TestReportPath -Architecture $Architecture -Configuration $Configuration -Suite 'compatibility' - Invoke-Native -FilePath $testExecutable -Arguments @( - '[compatibility]', - '--reporter', 'compact', - '--reporter', "JUnit::out=$compatibilityReportPath", - '--durations', 'yes', - '--order', 'lex' - ) -WorkingDirectory $binaryDirectory - Write-Host "Report: $compatibilityReportPath" - } - } finally { - [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $previousCorpus, 'Process') - } -} - -function Invoke-Test { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter(Mandatory)][string] $Configuration, - [Parameter()][string] $CorpusPath - ) - - Invoke-Build -Architectures $Architectures -Configuration $Configuration - foreach ($architecture in $Architectures) { - Invoke-TestExecutable -Architecture $architecture -Configuration $Configuration -CorpusPath $CorpusPath - } -} - -function Invoke-FormatCheck { - $llvm = Resolve-Llvm - $files = @( - Get-ChildItem -Recurse -File -LiteralPath (Join-Path $script:RepositoryRoot 'src') -Include '*.cpp', '*.h', '*.hpp' | - Select-Object -ExpandProperty FullName - ) - Write-Step 'Checking C++ formatting' - Invoke-Native -FilePath $llvm.Format -Arguments (@('--dry-run', '--Werror') + $files) -} - -function Invoke-Cppcheck { - param([Parameter(Mandatory)][string[]] $Architectures) - - $cppcheck = Resolve-Cppcheck - $sourceDirectory = Join-Path $script:RepositoryRoot 'src' - $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\cppcheck' - New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null - - $failures = [System.Collections.Generic.List[string]]::new() - foreach ($architecture in $Architectures) { - $platform = if ($architecture -eq 'x86') { 'win32W' } else { 'win64' } - $architectureDefine = switch ($architecture) { - 'x86' { '_M_IX86=600' } - 'x64' { '_M_X64=100' } - 'arm64' { '_M_ARM64=1' } - } - - $triplet = Get-VcpkgTriplet -Architecture $architecture - $vcpkgIncludeDirectory = Join-Path $script:ArtifactsRoot "vcpkg_installed\$architecture\$triplet\include" - if (-not (Test-Path -LiteralPath $vcpkgIncludeDirectory -PathType Container)) { - throw "Cppcheck dependency headers were not restored for ${architecture}: $vcpkgIncludeDirectory" - } - $buildDirectory = Join-Path $script:ArtifactsRoot "cppcheck\$architecture" - $reportPath = Join-Path $reportDirectory "cppcheck-$architecture.sarif" - New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null - - Write-Step "Cppcheck: all C++ sources for $architecture" - try { - Invoke-Native -FilePath $cppcheck -Arguments @( - $sourceDirectory, - '--std=c++23', - "--platform=$platform", - '-DWIN32=1', - '-D_WIN32=1', - '-DUNICODE=1', - '-D_UNICODE=1', - "-D$architectureDefine", - "-I$sourceDirectory", - "-I$vcpkgIncludeDirectory", - '--enable=warning,style,performance,portability', - '--check-level=exhaustive', - '--inconclusive', - '--inline-suppr', - '--error-exitcode=1', - '--suppress=missingIncludeSystem', - '--suppress=uninitMemberVarNoCtor:src/api.h', - # Dependency diagnostics belong to their upstream projects; keep every enabled rule active for first-party sources. - '--suppress=*:.artifacts/vcpkg_installed/*', - # Each plugin currently supplies one non-polymorphic extractor implementation; this changes in the parser-core refactor. - '--suppress=functionStatic', - "--relative-paths=$script:RepositoryRoot", - '--output-format=sarif', - "--output-file=$reportPath", - "--cppcheck-build-dir=$buildDirectory" - ) - } catch { - $failures.Add("${architecture}: $($_.Exception.Message)") - } finally { - if (Test-Path -LiteralPath $reportPath) { - Write-Host "Report: $reportPath" - } - } - } - - if ($failures.Count -gt 0) { - throw "Cppcheck failed:`n$($failures -join "`n")" - } -} - -function Invoke-PowerShellAnalysis { - $module = Get-Module -ListAvailable PSScriptAnalyzer | - Sort-Object Version -Descending | - Select-Object -First 1 - if (-not $module) { - throw 'PSScriptAnalyzer was not found. Install-Module PSScriptAnalyzer -Scope CurrentUser.' - } - - Import-Module $module.Path - $settings = Join-Path $script:BuildRoot 'PSScriptAnalyzerSettings.psd1' - $diagnostics = @( - Invoke-ScriptAnalyzer -Path (Join-Path $script:RepositoryRoot 'build.ps1') -Settings $settings - Invoke-ScriptAnalyzer -Path $script:BuildRoot -Recurse -Settings $settings - ) - - $rules = @( - $diagnostics | - Group-Object RuleName | - ForEach-Object { - [ordered]@{ - id = $_.Name - name = $_.Name - shortDescription = [ordered]@{ text = $_.Group[0].Message } - } - } - ) - $results = @( - foreach ($diagnostic in $diagnostics) { - $level = switch ($diagnostic.Severity.ToString()) { - 'Error' { 'error' } - 'Warning' { 'warning' } - default { 'note' } - } - $relativePath = [System.IO.Path]::GetRelativePath($script:RepositoryRoot, $diagnostic.ScriptPath).Replace('\', '/') - [ordered]@{ - ruleId = $diagnostic.RuleName - level = $level - message = [ordered]@{ text = $diagnostic.Message } - locations = @( - [ordered]@{ - physicalLocation = [ordered]@{ - artifactLocation = [ordered]@{ uri = $relativePath } - region = [ordered]@{ - startLine = [int] $diagnostic.Line - startColumn = [int] $diagnostic.Column - } - } - } - ) - } - } - ) - $sarif = [ordered]@{ - version = '2.1.0' - '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' - runs = @( - [ordered]@{ - tool = [ordered]@{ - driver = [ordered]@{ - name = 'PSScriptAnalyzer' - version = $module.Version.ToString() - informationUri = 'https://github.com/PowerShell/PSScriptAnalyzer' - rules = $rules - } - } - results = $results - } - ) - } - $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\psscriptanalyzer' - $reportPath = Join-Path $reportDirectory 'psscriptanalyzer.sarif' - New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null - $sarif | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 - Write-Host "Report: $reportPath" - - if ($diagnostics.Count -gt 0) { - Write-Host ($diagnostics | Format-Table -AutoSize | Out-String) - throw "PSScriptAnalyzer reported $($diagnostics.Count) diagnostic(s)." - } -} - -function Invoke-BuildContractTest { - $testDirectory = Join-Path $script:BuildRoot 'tests' - $tests = @(Get-ChildItem -LiteralPath $testDirectory -File -Filter '*.Tests.ps1' | Sort-Object Name) - if ($tests.Count -eq 0) { - throw "No build contract tests were found in '$testDirectory'." - } - - Write-Step "Running $($tests.Count) build contract test(s)" - foreach ($test in $tests) { - & $test.FullName - } -} - -function Invoke-Lint { - param([Parameter(Mandatory)][string[]] $Architectures) - - $requestedArchitectures = $Architectures - $failures = [System.Collections.Generic.List[string]]::new() - foreach ($check in @( - @{ Name = 'clang-format'; Action = { Invoke-FormatCheck } }, - @{ Name = 'Cppcheck'; Action = { Invoke-Cppcheck -Architectures $requestedArchitectures } }, - @{ Name = 'build contracts'; Action = { Invoke-BuildContractTest } }, - @{ Name = 'PSScriptAnalyzer'; Action = { - Write-Step 'Checking PowerShell sources' - Invoke-PowerShellAnalysis - } } - )) { - try { - & $check.Action - } catch { - $failures.Add("$($check.Name): $($_.Exception.Message)") - } - } - - if ($failures.Count -gt 0) { - throw "Source checks failed:`n$($failures -join "`n")" - } -} - -function Invoke-CodeAnalysis { - param([Parameter(Mandatory)][string[]] $Architectures) - - $llvm = Resolve-Llvm - $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\msvc' - foreach ($architecture in $Architectures) { - New-Item -ItemType Directory -Force -Path (Join-Path $reportDirectory $architecture) | Out-Null - } - try { - Invoke-Build -Architectures $Architectures -Configuration 'Debug' -Target 'Rebuild' -Properties @{ - ObserverRunCodeAnalysis = 'true' - ObserverAnalysisReportDirectory = $reportDirectory - ObserverEnableClangTidy = 'true' - LLVMInstallDir = $llvm.Root - } - } finally { - foreach ($architecture in $Architectures) { - Set-MSVCAnalysisSarifIdentity ` - -ReportDirectory (Join-Path $reportDirectory $architecture) ` - -Architecture $architecture - - $clangTidyReportPath = Join-Path ` - $script:ArtifactsRoot ` - "reports\clang-tidy\$architecture\clang-tidy.sarif" - Export-ClangTidySarif ` - -RepositoryRoot $script:RepositoryRoot ` - -ObjectRoot (Join-Path $script:ArtifactsRoot "obj\$architecture") ` - -OutputPath $clangTidyReportPath ` - -Architecture $architecture - Write-Host "clang-tidy SARIF report: $clangTidyReportPath" - } - } - Write-Host "MSVC SARIF reports: $reportDirectory" -} - -function Add-ASanRuntimeToPath { - param([Parameter(Mandatory)][string] $Architecture) - - $visualStudio = Resolve-VisualStudio - $targetDirectory = if ($Architecture -eq 'x86') { 'x86' } else { 'x64' } - $runtimeDirectory = Join-Path $visualStudio.ToolsetDirectory "bin\Hostx64\$targetDirectory" - $runtime = Get-ChildItem -File -LiteralPath $runtimeDirectory -Filter 'clang_rt.asan_dynamic-*.dll' | Select-Object -First 1 - if (-not $runtime) { - throw "The MSVC AddressSanitizer runtime was not found in '$runtimeDirectory'." - } - [Environment]::SetEnvironmentVariable('PATH', "$runtimeDirectory;$env:PATH", 'Process') -} - -function Invoke-ASan { - param([Parameter(Mandatory)][string[]] $Architectures) - - foreach ($architecture in $Architectures) { - if ($architecture -eq 'arm64') { - throw 'MSVC AddressSanitizer does not support ARM64. Use x86 or x64.' - } - } - - Invoke-Restore -Architectures $Architectures -Flavor 'asan' - Invoke-Build -Architectures $Architectures -Configuration 'ASan' - $previousOptions = [Environment]::GetEnvironmentVariable('ASAN_OPTIONS', 'Process') - try { - [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', 'halt_on_error=1:alloc_dealloc_mismatch=1', 'Process') - foreach ($architecture in $Architectures) { - Add-ASanRuntimeToPath -Architecture $architecture - Invoke-TestExecutable -Architecture $architecture -Configuration 'ASan' - } - } finally { - [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', $previousOptions, 'Process') - } -} - -function Invoke-Ubsan { - param([Parameter(Mandatory)][string[]] $Architectures) - - foreach ($architecture in $Architectures) { - if ($architecture -ne 'x64') { - throw 'The clang-cl UBSan configuration is intentionally x64-only. Release and MSVC test builds still cover x86, x64, and ARM64.' - } - - $llvm = Resolve-Llvm - $llvmRuntimeDirectory = Get-ChildItem -Directory -Path (Join-Path $llvm.Root 'lib\clang\*\lib\windows') | - Sort-Object FullName -Descending | - Select-Object -First 1 - if (-not $llvmRuntimeDirectory) { - throw "LLVM sanitizer runtimes were not found under '$($llvm.Root)'." - } - - Invoke-Restore -Architectures @($architecture) - Write-Step "Building tests with clang-cl UBSan for $architecture" - Invoke-MSBuild -Target 'Build' -Architecture $architecture -Configuration 'UBSan' -Properties @{ - LLVMInstallDir = $llvm.Root - LLVMRuntimeDir = $llvmRuntimeDirectory.FullName - } - - $previousOptions = [Environment]::GetEnvironmentVariable('UBSAN_OPTIONS', 'Process') - try { - [Environment]::SetEnvironmentVariable('UBSAN_OPTIONS', 'halt_on_error=1:print_stacktrace=1', 'Process') - Invoke-TestExecutable -Architecture $architecture -Configuration 'UBSan' - } finally { - [Environment]::SetEnvironmentVariable('UBSAN_OPTIONS', $previousOptions, 'Process') - } - } -} - -function Read-LeakProbeMarker { - param( - [Parameter(Mandatory)][System.Diagnostics.Process] $Process, - [Parameter(Mandatory)][string] $Marker, - [Parameter()][string] $Label - ) - - while ($true) { - try { - $line = $Process.StandardOutput.ReadLineAsync().WaitAsync([TimeSpan]::FromSeconds(60)).GetAwaiter().GetResult() - } catch [TimeoutException] { - throw "Leak probe produced no $Marker $Label marker within 60 seconds." - } - if ($null -eq $line) { - $errorOutput = $Process.StandardError.ReadToEnd() - throw "Leak probe exited before $Marker $Label. $errorOutput" - } - Write-Host $line - if ($line.StartsWith('OBSERVER_LEAK_PROBE|ERROR|', [StringComparison]::Ordinal)) { - throw $line - } - - $expectedPrefix = if ($Label) { - "OBSERVER_LEAK_PROBE|$Marker|$Label|" - } else { - "OBSERVER_LEAK_PROBE|$Marker|" - } - if ($line.StartsWith($expectedPrefix, [StringComparison]::Ordinal)) { - return $line - } - } -} - -function Invoke-LeakProbePreflight { - param( - [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, - [Parameter(Mandatory)][string] $Probe, - [Parameter(Mandatory)][string] $BinaryDirectory - ) - - $expectedScenarios = 'small-success,malformed,cancellation,read-failure,write-failure,large-metadata,sparse-metadata' - $output = Invoke-NativeCapture -FilePath $Probe -Arguments @( - '--automatic', - '--mode', $Mode, - '--warmup', '1', - '--iterations', '1', - '--windows', '3' - ) -WorkingDirectory $BinaryDirectory - $ready = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|READY|', [StringComparison]::Ordinal) }) - if ($ready.Count -ne 1 -or - $ready[0] -notmatch "\|mode=$Mode\|configuration=Release\|scenarios=$([regex]::Escape($expectedScenarios))$") { - throw "Leak probe $Mode preflight did not report the required Release scenario contract." - } - if (@($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|DONE|', [StringComparison]::Ordinal) }).Count -ne 1) { - throw "Leak probe $Mode preflight did not complete." - } -} - -function Assert-UmdhSnapshotUsable { - param([Parameter(Mandatory)][string] $Path) - - $snapshot = Get-Content -Raw -LiteralPath $Path - if ($snapshot -match "didn't find any allocations|database is full|stack trace database.*full") { - throw "UMDH snapshot reports unusable allocation-stack data: $Path" - } - if ($snapshot -notmatch 'BackTrace') { - throw "UMDH snapshot contains no allocation backtraces: $Path" - } -} - -function Compare-UmdhSnapshot { - param( - [Parameter(Mandatory)][string] $Umdh, - [Parameter(Mandatory)][string] $Before, - [Parameter(Mandatory)][string] $After, - [Parameter(Mandatory)][string] $Output, - [Parameter(Mandatory)][string] $Label - ) - - [void](Invoke-Native -FilePath $Umdh -Arguments @('-d', $Before, $After, "-f:$Output")) - $lines = @(Get-Content -LiteralPath $Output) - $totalLine = $lines | Where-Object { $_ -match '^Total (increase|decrease)\s*==' } | Select-Object -Last 1 - if (-not $totalLine -or $totalLine -notmatch '^Total (increase|decrease)\s*==\s*([0-9]+)') { - throw "UMDH comparison did not contain a total allocation delta: $Output" - } - $direction = $Matches[1] - $totalIncrease = [int64]::Parse($Matches[2], [Globalization.CultureInfo]::InvariantCulture) - if ($direction -eq 'decrease') { - $totalIncrease = -$totalIncrease - } - - $positiveStacks = @{} - foreach ($line in $lines) { - if ($line -match '^\+\s+([0-9]+)\s+\([^)]*\)\s+[0-9]+\s+allocs\s+BackTrace\s*([0-9A-Fa-f]+)') { - $positiveStacks[$Matches[2]] = [int64]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) - } - } - - return [pscustomobject]@{ - Label = $Label - TotalIncrease = $totalIncrease - PositiveStacks = $positiveStacks - Report = $Output - } -} - -function Invoke-LeakProbeMode { - param( - [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, - [Parameter(Mandatory)][string] $Probe, - [Parameter(Mandatory)][string] $BinaryDirectory, - [Parameter(Mandatory)] $DebuggingTools, - [Parameter(Mandatory)][string] $ReportDirectory, - [Parameter(Mandatory)][int] $Warmup, - [Parameter(Mandatory)][int] $Iterations, - [Parameter(Mandatory)][int] $Windows, - [Parameter(Mandatory)][int64] $ToleranceBytes - ) - - $modeDirectory = Join-Path $ReportDirectory $Mode - New-Item -ItemType Directory -Force -Path $modeDirectory | Out-Null - $startInfo = [System.Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = $Probe - $startInfo.WorkingDirectory = $BinaryDirectory - $startInfo.UseShellExecute = $false - $startInfo.RedirectStandardInput = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - $startInfo.CreateNoWindow = $true - foreach ($argument in @('--mode', $Mode, '--warmup', $Warmup, '--iterations', $Iterations, '--windows', $Windows)) { - $startInfo.ArgumentList.Add([string]$argument) - } - $startInfo.Environment['_NT_SYMBOL_PATH'] = $BinaryDirectory - $startInfo.Environment['OANOCACHE'] = '1' - - $process = [System.Diagnostics.Process]::new() - $process.StartInfo = $startInfo - $snapshots = [System.Collections.Generic.List[string]]::new() - $started = $false - try { - if (-not $process.Start()) { - throw "Failed to start leak probe: $Probe" - } - $started = $true - [void](Read-LeakProbeMarker -Process $process -Marker 'READY') - - $labels = @('baseline') + @(1..$Windows | ForEach-Object { "window-$_" }) - foreach ($label in $labels) { - $marker = Read-LeakProbeMarker -Process $process -Marker 'SNAPSHOT' -Label $label - if ($marker -notmatch '\|pid=([0-9]+)\|') { - throw "Leak probe snapshot marker has no PID: $marker" - } - $reportedPid = [int]$Matches[1] - if ($reportedPid -ne $process.Id) { - throw "Leak probe reported PID $reportedPid, but the started process is $($process.Id)." - } - - $snapshot = Join-Path $modeDirectory "$label.txt" - if ($label -eq 'baseline') { - & $DebuggingTools.Umdh "-p:$($process.Id)" "-f:$snapshot" - $snapshotExitCode = $LASTEXITCODE - $snapshotText = if (Test-Path -LiteralPath $snapshot) { - Get-Content -Raw -LiteralPath $snapshot - } else { - '' - } - if ($snapshotExitCode -notin @(0, 1) -or - ($snapshotExitCode -eq 1 -and $snapshotText -notmatch 'enabled allocation stack collection')) { - throw "UMDH could not prime allocation stack collection for PID $($process.Id) (exit $snapshotExitCode)." - } - } else { - Invoke-Native -FilePath $DebuggingTools.Umdh -Arguments @("-p:$($process.Id)", "-f:$snapshot") - Assert-UmdhSnapshotUsable -Path $snapshot - } - $snapshots.Add($snapshot) - $process.StandardInput.WriteLine("continue|$label") - $process.StandardInput.Flush() - } - - [void](Read-LeakProbeMarker -Process $process -Marker 'DONE') - if (-not $process.WaitForExit(120000)) { - throw "Leak probe did not exit after its final snapshot ($Mode)." - } - $errorOutput = $process.StandardError.ReadToEnd() - if ($process.ExitCode -ne 0) { - throw "Leak probe failed with exit code $($process.ExitCode): $errorOutput" - } - if ($errorOutput) { - Write-Host $errorOutput - } - } finally { - if ($started -and -not $process.HasExited) { - $process.Kill($true) - $process.WaitForExit() - } - $process.Dispose() - } - - # The first UMDH attachment enables per-process allocation stack collection without requiring an elevated, - # persistent GFlags registry setting. It is deliberately a priming snapshot. window-1 becomes the measured - # baseline after another complete workload window has run with stack collection active. - $analysisSnapshots = @($snapshots | Select-Object -Skip 1) - $comparisons = [System.Collections.Generic.List[object]]::new() - for ($index = 1; $index -lt $analysisSnapshots.Count; ++$index) { - $label = "window-$index" - $comparisonPath = Join-Path $modeDirectory "growth-$label.txt" - $comparisons.Add((Compare-UmdhSnapshot -Umdh $DebuggingTools.Umdh -Before $analysisSnapshots[$index - 1] -After $analysisSnapshots[$index] -Output $comparisonPath -Label $label)) - } - $overallPath = Join-Path $modeDirectory 'growth-overall.txt' - $overall = Compare-UmdhSnapshot -Umdh $DebuggingTools.Umdh -Before $analysisSnapshots[0] -After $analysisSnapshots[$analysisSnapshots.Count - 1] -Output $overallPath -Label 'overall' - - $last = $comparisons[$comparisons.Count - 1] - $previous = $comparisons[$comparisons.Count - 2] - $repeatedGrowingStacks = @( - $last.PositiveStacks.Keys | Where-Object { - $previous.PositiveStacks.ContainsKey($_) -and - $last.PositiveStacks[$_] -gt $ToleranceBytes -and - $previous.PositiveStacks[$_] -gt $ToleranceBytes - } - ) - $sustainedTotalGrowth = $last.TotalIncrease -gt $ToleranceBytes -and - $previous.TotalIncrease -gt $ToleranceBytes -and $overall.TotalIncrease -gt (2 * $ToleranceBytes) - - $summary = [ordered]@{ - mode = $Mode - warmupRounds = $Warmup - iterationsPerWindow = $Iterations - windows = $Windows - initialSnapshot = 'UMDH stack-collection priming only' - measuredBaseline = 'window-1' - toleranceBytes = $ToleranceBytes - totalGrowthByWindow = @($comparisons | ForEach-Object { $_.TotalIncrease }) - overallGrowthBytes = $overall.TotalIncrease - repeatedGrowingStacks = $repeatedGrowingStacks - passed = -not $sustainedTotalGrowth -and $repeatedGrowingStacks.Count -eq 0 - } - $summaryPath = Join-Path $modeDirectory 'summary.json' - $summary | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $summaryPath -Encoding utf8 - if (-not $summary.passed) { - throw "UMDH found sustained heap growth in $Mode mode. Summary: $summaryPath" - } - Write-Host "[OK] UMDH $Mode mode: no sustained growth. Summary: $summaryPath" -} - -function Invoke-LeakTest { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter(Mandatory)][int] $Warmup, - [Parameter(Mandatory)][int] $Iterations, - [Parameter(Mandatory)][int] $Windows, - [Parameter(Mandatory)][int64] $ToleranceBytes - ) - - if ($Architectures.Count -ne 1 -or $Architectures[0] -ne 'x64') { - throw 'UMDH leak testing is intentionally x64-only. Use -Arch x64.' - } - - $debuggingTools = Resolve-WindowsDebuggingTool - Invoke-Restore -Architectures @('x64') - Write-Step 'Building the shipping x64 Release /MT leak probe and module DLLs' - Invoke-MSBuild -Target 'BuildLeakProbe' -Architecture 'x64' -Configuration 'Release' - $binaryDirectory = Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release' - $probe = Join-Path $binaryDirectory 'leak-probe.exe' - if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { - throw "Leak probe was not found: $probe" - } - - $reportDirectory = Join-Path $script:ArtifactsRoot 'reports\leaks\x64' - if (Test-Path -LiteralPath $reportDirectory) { - Remove-Item -Recurse -Force -LiteralPath $reportDirectory - } - New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null - - $moduleEvidence = @( - foreach ($moduleName in $script:ModuleNames) { - Assert-ReleaseBinary -Architecture 'x64' -ModuleName $moduleName - $modulePath = Join-Path $binaryDirectory "$moduleName.so" - [ordered]@{ - module = $moduleName - path = $modulePath - sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $modulePath).Hash - } - } - ) - $binaryEvidence = [ordered]@{ - architecture = 'x64' - configuration = 'Release' - runtimeLibrary = 'MT_StaticRelease' - probe = [ordered]@{ - path = $probe - sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $probe).Hash - } - modules = $moduleEvidence - } - $binaryEvidence | ConvertTo-Json -Depth 6 | - Set-Content -LiteralPath (Join-Path $reportDirectory 'release-binaries.json') -Encoding utf8 - - foreach ($mode in @('operations', 'lifecycle')) { - Write-Step "Release leak-probe preflight: $mode" - Invoke-LeakProbePreflight -Mode $mode -Probe $probe -BinaryDirectory $binaryDirectory - } - - $previousSymbolPath = [Environment]::GetEnvironmentVariable('_NT_SYMBOL_PATH', 'Process') - try { - [Environment]::SetEnvironmentVariable('_NT_SYMBOL_PATH', $binaryDirectory, 'Process') - foreach ($mode in @('operations', 'lifecycle')) { - Write-Step "UMDH leak test: $mode" - Invoke-LeakProbeMode -Mode $mode -Probe $probe -BinaryDirectory $binaryDirectory -DebuggingTools $debuggingTools -ReportDirectory $reportDirectory -Warmup $Warmup -Iterations $Iterations -Windows $Windows -ToleranceBytes $ToleranceBytes - } - } finally { - [Environment]::SetEnvironmentVariable('_NT_SYMBOL_PATH', $previousSymbolPath, 'Process') - } -} - -function Initialize-FuzzCorpus { - param( - [Parameter(Mandatory)][string] $SeedDirectory, - [Parameter(Mandatory)][string] $CorpusDirectory - ) - - if (-not (Test-Path -LiteralPath $SeedDirectory -PathType Container)) { - throw "Fuzzer seed directory was not found: $SeedDirectory" - } - New-Item -ItemType Directory -Force -Path $CorpusDirectory | Out-Null - - foreach ($seed in Get-ChildItem -LiteralPath $SeedDirectory -File) { - if ($seed.Extension -eq '.hex') { - $hex = (Get-Content -LiteralPath $seed.FullName -Raw) -replace '\s', '' - if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { - throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" - } - $destination = Join-Path $CorpusDirectory $seed.BaseName - [System.IO.File]::WriteAllBytes($destination, [Convert]::FromHexString($hex)) - } else { - Copy-Item -LiteralPath $seed.FullName -Destination $CorpusDirectory -Force - } - } -} - -function Invoke-Fuzz { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter(Mandatory)][int] $Seconds, - [Parameter(Mandatory)][string] $TargetName - ) - - foreach ($architecture in $Architectures) { - if ($architecture -ne 'x64') { - throw 'The LLVM libFuzzer configuration is intentionally x64-only. Release and MSVC test builds still cover x86, x64, and ARM64.' - } - - $llvm = Resolve-Llvm - $llvmRuntimeDirectory = Get-ChildItem -Directory -Path (Join-Path $llvm.Root 'lib\clang\*\lib\windows') | - Sort-Object FullName -Descending | - Select-Object -First 1 - if (-not $llvmRuntimeDirectory) { - throw "LLVM sanitizer runtimes were not found under '$($llvm.Root)'." - } - - Invoke-Restore -Architectures @($architecture) -Flavor 'asan' - Write-Step "Building all parser fuzzers for $architecture" - Invoke-MSBuild -Target 'BuildFuzz' -Architecture $architecture -Configuration 'Fuzz' -Properties @{ - LLVMInstallDir = $llvm.Root - LLVMRuntimeDir = $llvmRuntimeDirectory.FullName - } - - [Environment]::SetEnvironmentVariable('PATH', "$($llvmRuntimeDirectory.FullName);$env:PATH", 'Process') - - $targets = @( - [pscustomobject]@{ Name = 'pickle'; MaxLength = 262144 }, - [pscustomobject]@{ Name = 'renpy'; MaxLength = 1048576 }, - [pscustomobject]@{ Name = 'rpgmaker'; MaxLength = 1048576 }, - [pscustomobject]@{ Name = 'zanzarah'; MaxLength = 1048576 } - ) - if ($TargetName -ne 'all') { - $targets = @($targets | Where-Object Name -eq $TargetName) - } - $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Fuzz' - $previousOptions = [Environment]::GetEnvironmentVariable('ASAN_OPTIONS', 'Process') - try { - [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', 'halt_on_error=1:alloc_dealloc_mismatch=1', 'Process') - foreach ($target in $targets) { - $fuzzer = Join-Path $binaryDirectory "fuzz-$($target.Name).exe" - if (-not (Test-Path -LiteralPath $fuzzer -PathType Leaf)) { - throw "Fuzzer executable was not found: $fuzzer" - } - - $seedCorpusDirectory = Join-Path $script:RepositoryRoot "src\fuzz\corpus\$($target.Name)" - $fuzzDirectory = Join-Path $script:ArtifactsRoot "fuzz\$architecture\$($target.Name)" - $corpusDirectory = Join-Path $fuzzDirectory 'corpus' - $artifactDirectory = Join-Path $fuzzDirectory 'artifacts' - if (Test-Path -LiteralPath $artifactDirectory) { - Remove-Item -Recurse -Force -LiteralPath $artifactDirectory - } - New-Item -ItemType Directory -Force -Path $artifactDirectory | Out-Null - - $seedReplayDirectory = Join-Path $fuzzDirectory "seed-replay\$([Guid]::NewGuid().ToString('N'))" - Initialize-FuzzCorpus -SeedDirectory $seedCorpusDirectory -CorpusDirectory $seedReplayDirectory - $seedInputs = @(Get-ChildItem -LiteralPath $seedReplayDirectory -File | Sort-Object Name) - if ($seedInputs.Count -eq 0) { - throw "No checked-in fuzzer seeds were found for $($target.Name)." - } - - Write-Step "Replaying $($seedInputs.Count) checked-in $($target.Name) seed(s)" - Invoke-Native -FilePath $fuzzer -Arguments (@($seedInputs.FullName) + @( - "-max_len=$($target.MaxLength)", - '-rss_limit_mb=1024', - '-timeout=10', - '-print_final_stats=1', - "-artifact_prefix=$artifactDirectory\" - )) -WorkingDirectory $binaryDirectory - - Initialize-FuzzCorpus -SeedDirectory $seedCorpusDirectory -CorpusDirectory $corpusDirectory - - Write-Step "Fuzzing $($target.Name) for $Seconds second(s)" - Invoke-Native -FilePath $fuzzer -Arguments @( - $corpusDirectory, - "-max_total_time=$Seconds", - "-max_len=$($target.MaxLength)", - '-rss_limit_mb=1024', - '-timeout=10', - '-use_value_profile=1', - '-print_final_stats=1', - "-artifact_prefix=$artifactDirectory\" - ) -WorkingDirectory $binaryDirectory - } - } finally { - [Environment]::SetEnvironmentVariable('ASAN_OPTIONS', $previousOptions, 'Process') - } - } -} - -function Invoke-Coverage { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter()][string] $CorpusPath, - [Parameter(Mandatory)][double] $Threshold - ) - - $llvm = Resolve-Llvm - $ignoredSources = '([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])' - - foreach ($architecture in $Architectures) { - if (-not (Test-CanRunArchitecture $architecture)) { - throw "The current host cannot execute $architecture coverage binaries." - } - - Invoke-Build -Architectures @($architecture) -Configuration 'Coverage' -Target 'Rebuild' -Properties @{ - LLVMInstallDir = $llvm.Root - } - $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Coverage' - $coverageDirectory = Join-Path $script:ArtifactsRoot "coverage\$architecture" - $runDirectory = Join-Path $coverageDirectory ([Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null - $rawProfilePattern = Join-Path $runDirectory 'observer-%m-%p.profraw' - $profilePath = Join-Path $coverageDirectory 'coverage.profdata' - $jsonReportPath = Join-Path $coverageDirectory 'coverage.json' - $lcovReportPath = Join-Path $coverageDirectory 'coverage.lcov' - $testExecutable = Join-Path $binaryDirectory 'tests.exe' - $coverageObjects = @($script:ModuleNames | ForEach-Object { Join-Path $binaryDirectory "$_.so" }) - - $previousCorpus = [Environment]::GetEnvironmentVariable('OBSERVER_TEST_CORPUS', 'Process') - $previousProfile = [Environment]::GetEnvironmentVariable('LLVM_PROFILE_FILE', 'Process') - try { - if ($CorpusPath) { - [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', (Resolve-UserPath -Path $CorpusPath), 'Process') - } - [Environment]::SetEnvironmentVariable('LLVM_PROFILE_FILE', $rawProfilePattern, 'Process') - Write-Step "Collecting LLVM source coverage for $architecture" - $testReportPath = Get-TestReportPath -Architecture $architecture -Configuration 'Coverage' - Invoke-Native -FilePath $testExecutable -Arguments @( - '--reporter', 'compact', - '--reporter', "JUnit::out=$testReportPath", - '--durations', 'yes', - '--order', 'lex' - ) -WorkingDirectory $binaryDirectory - Write-Host "Test report: $testReportPath" - } finally { - [Environment]::SetEnvironmentVariable('OBSERVER_TEST_CORPUS', $previousCorpus, 'Process') - [Environment]::SetEnvironmentVariable('LLVM_PROFILE_FILE', $previousProfile, 'Process') - } - - $rawProfiles = @(Get-ChildItem -File -LiteralPath $runDirectory -Filter '*.profraw') - if ($rawProfiles.Count -eq 0) { - throw "The instrumented test run produced no LLVM raw profiles in '$runDirectory'." - } - - $mergeArguments = @('merge', '-sparse') + @($rawProfiles.FullName) + @('-o', $profilePath) - Invoke-Native -FilePath $llvm.Profdata -Arguments $mergeArguments - - $objectArguments = @($coverageObjects | ForEach-Object { @('--object', $_) }) - $commonArguments = @( - $testExecutable, - '--instr-profile', $profilePath, - '--ignore-filename-regex', $ignoredSources - ) + $objectArguments - - $summaryJson = Invoke-NativeCapture -FilePath $llvm.Cov -Arguments (@('export') + $commonArguments + @('--summary-only')) - $summaryText = @($summaryJson | Where-Object { $_ -notmatch '^warning:' }) -join "`n" - Set-Content -LiteralPath $jsonReportPath -Value $summaryText -Encoding utf8 - $report = $summaryText | ConvertFrom-Json - $totals = $report.data[0].totals - if ($totals.branches.count -eq 0) { - throw "LLVM coverage report contains no source branches: $jsonReportPath" - } - - Invoke-Native -FilePath $llvm.Cov -Arguments (@('report') + $commonArguments + @('--show-branch-summary')) - $lcov = Invoke-NativeCapture -FilePath $llvm.Cov -Arguments (@('export') + $commonArguments + @('--format=lcov')) - $lcovText = @($lcov | Where-Object { $_ -notmatch '^warning:' }) -join "`n" - Set-Content -LiteralPath $lcovReportPath -Value $lcovText -Encoding utf8 - - $reportedMetrics = @( - [pscustomobject]@{ Name = 'branches'; Value = $totals.branches }, - [pscustomobject]@{ Name = 'functions'; Value = $totals.functions }, - [pscustomobject]@{ Name = 'lines'; Value = $totals.lines }, - [pscustomobject]@{ Name = 'regions'; Value = $totals.regions } - ) - $requiredMetrics = @($reportedMetrics | Where-Object { $_.Name -in @('branches', 'lines') }) - $failedMetrics = @($requiredMetrics | Where-Object { $_.Value.percent -lt $Threshold }) - - Write-Host "Reports: $jsonReportPath, $lcovReportPath" - if ($failedMetrics.Count -gt 0) { - $failures = $failedMetrics | ForEach-Object { "{0}={1:N2}%" -f $_.Name, $_.Value.percent } - throw "Coverage is below the required $Threshold%: $($failures -join ', ')." - } - } -} - -function Assert-ReleaseBinary { - param( - [Parameter(Mandatory)][string] $Architecture, - [Parameter(Mandatory)][string] $ModuleName - ) - - $visualStudio = Resolve-VisualStudio - $binary = Join-Path (Get-BinaryDirectory -Architecture $Architecture -Configuration 'Release') "$ModuleName.so" - if (-not (Test-Path -LiteralPath $binary)) { - throw "Release module was not found: $binary" - } - - $headers = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/headers', $binary) - $expectedMachine = switch ($Architecture) { - 'x86' { '14C machine \(x86\)' } - 'x64' { '8664 machine \(x64\)' } - 'arm64' { 'AA64 machine \(ARM64\)' } - } - if (($headers -join "`n") -notmatch $expectedMachine) { - throw "$ModuleName has the wrong PE machine type for $Architecture." - } - - $dependencyOutput = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/dependents', $binary) - $dependencies = @( - $dependencyOutput | - ForEach-Object { - if ($_ -match '^\s+([A-Za-z0-9._-]+\.dll)\s*$') { $Matches[1] } - } | - Select-Object -Unique - ) - $allowedDependencies = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - @('KERNEL32.dll', 'USER32.dll', 'ADVAPI32.dll', 'SHELL32.dll', 'OLE32.dll', 'OLEAUT32.dll', 'SHLWAPI.dll', 'BCRYPT.dll', 'NTDLL.dll') | - ForEach-Object { [void]$allowedDependencies.Add($_) } - $forbiddenRuntimePattern = '^(VCRUNTIME|MSVCP|UCRTBASE|api-ms-win-crt-|ext-ms-win-crt-|zlib|zstd|xxhash|clang_rt\.).*\.dll$' - $unexpectedDependencies = @( - $dependencies | Where-Object { - $_ -match $forbiddenRuntimePattern -or - (-not $allowedDependencies.Contains($_) -and $_ -notmatch '^(api|ext)-ms-win-.*\.dll$') - } - ) - if ($unexpectedDependencies.Count -gt 0) { - throw "$ModuleName has non-system DLL dependencies: $($unexpectedDependencies -join ', ')" - } - - $exportsOutput = Invoke-NativeCapture -FilePath $visualStudio.Dumpbin -Arguments @('/exports', $binary) - $exports = @( - $exportsOutput | ForEach-Object { - if ($_ -match '^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)') { $Matches[1] } - } - ) - $expectedExports = @('LoadSubModule', 'UnloadSubModule') - $exportDifference = @( - Compare-Object -ReferenceObject $expectedExports -DifferenceObject $exports | - ForEach-Object { "$($_.SideIndicator)$($_.InputObject)" } - ) - if ($exportDifference.Count -gt 0) { - throw "$ModuleName has an unexpected export surface: $($exportDifference -join ', ')" - } - - Write-Host "[OK] $ModuleName.so: $Architecture, static runtime/dependencies, expected Observer exports" -} - -function Invoke-BinSkimAudit { - param([Parameter(Mandatory)][string] $Architecture) - - $binSkim = Resolve-BinSkim - $binaryDirectory = Get-BinaryDirectory -Architecture $Architecture -Configuration 'Release' - $reportDirectory = Join-Path $script:ArtifactsRoot 'audit' - $reportPath = Join-Path $reportDirectory "binskim-$Architecture.sarif" - New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null - - $targets = @($script:ModuleNames | ForEach-Object { Join-Path $binaryDirectory "$_.so" }) - $arguments = @( - 'analyze' - ) + $targets + @( - '--level', 'Error;Warning', - '--kind', 'Fail', - '--local-symbol-directories', $binaryDirectory, - '--output', $reportPath, - '--log', 'ForceOverwrite', - '--quiet', - '--disable-telemetry' - ) - Invoke-Native -FilePath $binSkim -Arguments $arguments - - $report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json -Depth 100 - $ruleLevels = @{} - foreach ($run in $report.runs) { - foreach ($rule in @($run.tool.driver.rules)) { - $defaultConfiguration = $rule.PSObject.Properties['defaultConfiguration'] - $configuredLevel = if ($defaultConfiguration) { - $defaultConfiguration.Value.PSObject.Properties['level'] - } else { - $null - } - $ruleLevels[$rule.id] = if ($configuredLevel) { [string]$configuredLevel.Value } else { 'warning' } - } - } - - $results = @($report.runs | ForEach-Object { @($_.results) }) - $approvedWarnings = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - [void]$approvedWarnings.Add('BA2027') # SourceLink is tracked explicitly in docs/autonomous-work-log.md. - $unexpectedFindings = [System.Collections.Generic.List[string]]::new() - - foreach ($result in $results) { - $resultLevelProperty = $result.PSObject.Properties['level'] - $effectiveLevel = if ($resultLevelProperty) { [string]$resultLevelProperty.Value } else { $ruleLevels[$result.ruleId] } - if ($effectiveLevel -notin @('error', 'warning')) { - continue - } - - $artifactUri = [string]$result.locations[0].physicalLocation.artifactLocation.uri - $binaryName = [System.IO.Path]::GetFileName(([uri]$artifactUri).LocalPath) - $findingKey = "${binaryName}:$($result.ruleId)" - if ($effectiveLevel -eq 'error' -or -not $approvedWarnings.Contains([string]$result.ruleId)) { - $unexpectedFindings.Add("${effectiveLevel}:$findingKey") - } - } - - if ($unexpectedFindings.Count -gt 0) { - throw "BinSkim found unapproved findings: $($unexpectedFindings -join ', '). Report: $reportPath" - } - Write-Host "[OK] BinSkim: $($results.Count) finding(s), no unapproved errors. Report: $reportPath" -} - -function Invoke-Audit { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [switch] $BuildFirst - ) - - if ($BuildFirst) { - Invoke-Build -Architectures $Architectures -Configuration 'Release' - } - foreach ($architecture in $Architectures) { - Write-Step "Auditing Release binaries for $architecture" - foreach ($moduleName in $script:ModuleNames) { - Assert-ReleaseBinary -Architecture $architecture -ModuleName $moduleName - } - Invoke-BinSkimAudit -Architecture $architecture - } -} - -function Invoke-Doctor { - $checks = [System.Collections.Generic.List[object]]::new() - $checks.Add([pscustomobject]@{ Tool = 'PowerShell'; Status = 'OK'; Detail = $PSVersionTable.PSVersion.ToString() }) - - foreach ($probe in @( - @{ Name = 'Visual Studio/MSVC'; Action = { $value = Resolve-VisualStudio; "$($value.InstallationPath), MSVC $($value.ToolsetVersion)" } }, - @{ Name = 'vcpkg'; Action = { (Resolve-Vcpkg).Root } }, - @{ Name = 'LLVM'; Action = { (Resolve-Llvm).Root } }, - @{ Name = 'Cppcheck'; Action = { Resolve-Cppcheck } }, - @{ Name = 'BinSkim'; Action = { Resolve-BinSkim } }, - @{ Name = 'UMDH'; Action = { (Resolve-WindowsDebuggingTool).Umdh } }, - @{ Name = 'PSScriptAnalyzer'; Action = { - $module = Get-Module -ListAvailable PSScriptAnalyzer | Sort-Object Version -Descending | Select-Object -First 1 - if (-not $module) { throw 'PSScriptAnalyzer module was not found.' } - $module.Version.ToString() - } } - )) { - try { - $detail = & $probe.Action - if (-not $detail) { throw 'not found' } - $checks.Add([pscustomobject]@{ Tool = $probe.Name; Status = 'OK'; Detail = $detail }) - } catch { - $checks.Add([pscustomobject]@{ Tool = $probe.Name; Status = 'MISSING'; Detail = $_.Exception.Message }) - } - } - - Write-Host ($checks | Format-Table -AutoSize -Wrap | Out-String) - if ($checks.Status -contains 'MISSING') { - Write-Host 'Core build commands can still work when only analysis/coverage tools are missing.' -ForegroundColor Yellow - } -} - -function Invoke-Clean { - $resolvedArtifacts = [System.IO.Path]::GetFullPath($script:ArtifactsRoot) - $resolvedRepository = [System.IO.Path]::GetFullPath($script:RepositoryRoot) - if (-not $resolvedArtifacts.StartsWith($resolvedRepository, [StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to clean path outside the repository: $resolvedArtifacts" - } - if (Test-Path -LiteralPath $resolvedArtifacts) { - Remove-Item -Recurse -Force -LiteralPath $resolvedArtifacts - Write-Host "Removed $resolvedArtifacts" - } -} - -function Show-Help { - Write-Host @' -ObserverModules build entry point - - doctor inspect the complete toolchain - restore -Arch restore pinned static vcpkg dependencies - build -Arch build modules and tests - test -Arch build and run deterministic/corpus tests - source-checks -Arch clang-format, Cppcheck, PSScriptAnalyzer - compiler-analysis -Arch MSVC /analyze plus clang-tidy - test-coverage -Arch run tests and enforce llvm-cov source coverage - test-asan -Arch build dependencies/code and run tests with MSVC ASan - test-ubsan -Arch x64 build and run tests with clang-cl UBSan - test-leaks -Arch x64 run UMDH operation and DLL-lifecycle leak checks - fuzz -Arch x64 build and run one or all libFuzzer targets - audit-binaries -Arch build and inspect Release PE files with dumpbin - package -Arch module ZIPs plus one combined PDB ZIP - verify -Arch complete host-capable gate with explicit native deferrals - clean remove .artifacts - -Options: - -Config Debug|Release - -Corpus - -RestoreFlavor default|asan|all restore only; "all" prepares serial DAG dependency flavors - -SkipDependencyRestore DAG leaf only; dependencies must already be restored - -FuzzSeconds - -FuzzTarget pickle, renpy, rpgmaker, zanzarah, or all (default) - -LeakWarmup - -LeakIterations - -LeakWindows <3..10> - -LeakToleranceBytes defaults to zero; use only for a reviewed stack-specific exception - -CoverageThreshold <0..100> defaults to 100 for source lines and branches -'@ -} - -$architectures = Get-RequestedArchitecture -Requested $Arch -switch ($Command) { - 'help' { Show-Help } - 'doctor' { Invoke-Doctor } - 'restore' { - if ($SkipDependencyRestore) { - throw '-SkipDependencyRestore cannot be used with the restore command.' - } - Invoke-Restore -Architectures $architectures -Flavor $RestoreFlavor - } - 'build' { - Invoke-Restore -Architectures $architectures - Invoke-Build -Architectures $architectures -Configuration $Config - } - 'test' { - Invoke-Restore -Architectures $architectures - Invoke-Test -Architectures $architectures -Configuration $Config -CorpusPath $Corpus - } - 'source-checks' { - Invoke-Restore -Architectures $architectures - Invoke-Lint -Architectures $architectures - } - 'compiler-analysis' { - Invoke-Restore -Architectures $architectures - Invoke-CodeAnalysis -Architectures $architectures - } - 'test-coverage' { - Invoke-Restore -Architectures $architectures - Invoke-Coverage -Architectures $architectures -CorpusPath $Corpus -Threshold $CoverageThreshold - } - 'test-asan' { Invoke-ASan -Architectures $architectures } - 'test-ubsan' { Invoke-Ubsan -Architectures $architectures } - 'test-leaks' { - Invoke-LeakTest -Architectures $architectures -Warmup $LeakWarmup -Iterations $LeakIterations -Windows $LeakWindows -ToleranceBytes $LeakToleranceBytes - } - 'fuzz' { Invoke-Fuzz -Architectures $architectures -Seconds $FuzzSeconds -TargetName $FuzzTarget } - 'audit-binaries' { - Invoke-Restore -Architectures $architectures - Invoke-Audit -Architectures $architectures -BuildFirst - } - 'package' { Invoke-Package -Architectures $architectures } - 'verify' { - Invoke-Verify ` - -Architectures $architectures ` - -CorpusPath $Corpus ` - -RequiredCoverageThreshold $CoverageThreshold ` - -RequiredFuzzSeconds $FuzzSeconds ` - -RequiredLeakWarmup $LeakWarmup ` - -RequiredLeakIterations $LeakIterations ` - -RequiredLeakWindows $LeakWindows ` - -RequiredLeakToleranceBytes $LeakToleranceBytes - } - 'clean' { Invoke-Clean } -} diff --git a/build/dynamic_graph.py b/build/dynamic_graph.py deleted file mode 100644 index bdbbf19..0000000 --- a/build/dynamic_graph.py +++ /dev/null @@ -1,661 +0,0 @@ -"""Deterministic schema-v2 records for fine-grained dynamic verification leaves.""" - -from __future__ import annotations - -from collections.abc import Iterable, Sequence -from pathlib import PurePosixPath -import re - -from build.native_graph import ( - FUZZ_TARGET_NAMES as FUZZ_TARGETS, - build_project_node_name, - fuzz_build_node_name, -) - - -ARCHITECTURES = ("x86", "x64", "arm64") -MODULES = ("renpy", "rpgmaker", "zanzarah") -LEAK_MODES = ("operations", "lifecycle") -LEAK_SCENARIOS = ( - "small-success", - "malformed", - "cancellation", - "read-failure", - "write-failure", - "large-metadata", - "sparse-metadata", -) - -_NODE_FIELDS = { - "name", - "deps", - "run_after", - "argv", - "resources", - "inputs", - "writes", - "outputs", - "fingerprint", - "cacheable", -} -_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") - - -class DynamicTopologyError(ValueError): - """Raised when normalized dynamic topology is unsafe or inconsistent.""" - - -def _claim(name: str, units: int = 1, _scope: str = "task") -> tuple[str, int]: - return name, units - - -def _node( - name: str, - kind: str, - *, - deps: Iterable[str], - argv: Sequence[str], - resources: Iterable[tuple[str, int]], - writes: Iterable[str], - outputs: Iterable[str], - cacheable: bool, - inputs: Iterable[str] = ("build/lib/dynamic-graph-leaves.ps1",), -) -> dict[str, object]: - resource_items = list(resources) - if len({name for name, _ in resource_items}) != len(resource_items): - raise DynamicTopologyError(f"duplicate resource claim in {name}") - return { - "name": name, - "deps": sorted(deps), - "run_after": [], - "argv": list(argv), - "resources": dict(sorted(resource_items)), - "inputs": sorted(inputs), - "writes": sorted(writes), - "outputs": sorted(outputs), - "fingerprint": ["contract=dynamic-microdag-v1", f"kind={kind}", f"node={name}"], - "cacheable": cacheable, - } - - -def _leaf_argv(leaf: str, *arguments: str) -> list[str]: - return [ - "pwsh", - "-NoLogo", - "-NoProfile", - "-File", - "build/lib/dynamic-graph-leaves.ps1", - "-Leaf", - leaf, - *arguments, - ] - - -def _release_build_dependency(architecture: str, project: str) -> str: - if architecture == "x64": - return build_project_node_name("Release", project) - return f"release-{architecture}-{project}" - - -def _add_fuzz(nodes: list[dict[str, object]], run_id: str) -> None: - for target in FUZZ_TARGETS: - persistent_base = f".artifacts/fuzz/x64/{target}" - run_base = f".artifacts/fuzz-runs/{run_id}/x64/{target}" - build_name = fuzz_build_node_name(target) - replay_name = f"fuzz-seed-replay-x64-{target}" - timed_name = f"fuzz-timed-x64-{target}" - nodes.append( - _node( - replay_name, - "fuzz-seed-replay", - deps=(build_name,), - argv=_leaf_argv( - "fuzz", - "-Phase", - "seed-replay", - "-Architecture", - "x64", - "-TargetName", - target, - "-RunId", - run_id, - ), - resources=(_claim("cpu", 1), _claim("memory-gib", 2), _claim(f"fuzz-writer-x64-{target}")), - writes=( - f"{run_base}/seed-replay", - f"{run_base}/seed-replay/result.json", - f"{run_base}/seed-replay/artifacts", - ), - outputs=(f"{run_base}/seed-replay/result.json",), - cacheable=False, - ) - ) - nodes.append( - _node( - timed_name, - "fuzz-timed", - deps=(replay_name,), - argv=_leaf_argv( - "fuzz", - "-Phase", - "timed", - "-Architecture", - "x64", - "-TargetName", - target, - "-RunId", - run_id, - ), - resources=( - _claim("cpu", 1), - _claim("memory-gib", 2), - _claim("fuzz-runtime", 1), - _claim(f"fuzz-writer-x64-{target}"), - ), - writes=( - f"{persistent_base}/corpus", - f"{run_base}/timed", - f"{run_base}/timed/result.json", - f"{run_base}/timed/artifacts", - ), - outputs=(f"{run_base}/timed/result.json",), - cacheable=False, - ) - ) - - -def _add_leaks(nodes: list[dict[str, object]], leak_windows: int) -> None: - audit_deps = tuple(f"audit-x64-{module}" for module in MODULES) - nodes.append( - _node( - "leak-setup", - "leak-setup", - deps=(*audit_deps, _release_build_dependency("x64", "leak-probe")), - argv=_leaf_argv("leak", "-Phase", "setup", "-Architecture", "x64"), - resources=(_claim("cpu", 1),), - writes=(".artifacts/reports/leaks/x64/release-binaries.json",), - outputs=(".artifacts/reports/leaks/x64/release-binaries.json",), - cacheable=False, - ) - ) - judges: list[str] = [] - for mode in LEAK_MODES: - for scenario in LEAK_SCENARIOS: - stem = f"leak-{mode}-{scenario}" - preflight_name = f"{stem}-preflight" - capture_name = f"{stem}-capture" - capture_root = f".artifacts/reports/leaks/x64/captures/{mode}/{scenario}" - preflight_output = f".artifacts/reports/leaks/x64/preflight/{mode}/{scenario}.json" - capture_output = f"{capture_root}/capture.json" - common = ( - "-Mode", - mode, - "-Scenario", - scenario, - "-Architecture", - "x64", - ) - nodes.append( - _node( - preflight_name, - "leak-preflight", - deps=("leak-setup",), - argv=_leaf_argv("leak", "-Phase", "preflight", *common), - resources=(_claim("cpu", 1), _claim("memory-gib", 1)), - writes=(preflight_output,), - outputs=(preflight_output,), - cacheable=False, - ) - ) - nodes.append( - _node( - capture_name, - "leak-capture", - deps=(preflight_name,), - argv=_leaf_argv("leak", "-Phase", "capture", *common), - resources=( - _claim("cpu", 1), - _claim("memory-gib", 1), - _claim("umdh-session", 1), - _claim("umdh-capture", 1, "invocation"), - ), - writes=(capture_root, capture_output), - outputs=(capture_output,), - cacheable=False, - ) - ) - diff_names: list[str] = [] - for index in range(1, leak_windows): - label = f"window-{index}" - diff_name = f"{stem}-diff-{label}" - output = f".artifacts/reports/leaks/x64/diffs/{mode}/{scenario}/{label}.json" - nodes.append( - _node( - diff_name, - "leak-diff", - deps=(capture_name,), - argv=_leaf_argv("leak", "-Phase", "diff", *common, "-Label", label), - resources=(_claim("cpu", 1), _claim("umdh-diff", 1)), - writes=(output,), - outputs=(output,), - cacheable=False, - ) - ) - diff_names.append(diff_name) - overall_name = f"{stem}-diff-overall" - overall_output = f".artifacts/reports/leaks/x64/diffs/{mode}/{scenario}/overall.json" - nodes.append( - _node( - overall_name, - "leak-diff", - deps=(capture_name,), - argv=_leaf_argv("leak", "-Phase", "diff", *common, "-Label", "overall"), - resources=(_claim("cpu", 1), _claim("umdh-diff", 1)), - writes=(overall_output,), - outputs=(overall_output,), - cacheable=False, - ) - ) - diff_names.append(overall_name) - judge_name = f"{stem}-judge" - judge_output = f".artifacts/reports/leaks/x64/summaries/{mode}/{scenario}.json" - nodes.append( - _node( - judge_name, - "leak-judge", - deps=diff_names, - argv=_leaf_argv("leak", "-Phase", "judge", *common), - resources=(_claim("cpu", 1),), - writes=(judge_output,), - outputs=(judge_output,), - cacheable=False, - ) - ) - judges.append(judge_name) - nodes.append( - _node( - "leaks-aggregate", - "leaks-aggregate", - deps=judges, - argv=_leaf_argv("leak", "-Phase", "aggregate", "-Architecture", "x64"), - resources=(_claim("cpu", 1),), - writes=(".artifacts/reports/leaks/x64/summary.json",), - outputs=(".artifacts/reports/leaks/x64/summary.json",), - cacheable=False, - ) - ) - - -def _add_audits(nodes: list[dict[str, object]], architectures: Sequence[str]) -> None: - for architecture in architectures: - for module in MODULES: - stem = f"audit-{architecture}-{module}" - release = _release_build_dependency(architecture, module) - dumpbin = f"{stem}-dumpbin" - binskim = f"{stem}-binskim" - dumpbin_output = f".artifacts/audit/{architecture}/{module}/dumpbin.json" - binskim_output = f".artifacts/audit/{architecture}/{module}/binskim.sarif" - summary_output = f".artifacts/audit/{architecture}/{module}/summary.json" - nodes.append( - _node( - dumpbin, - "audit-dumpbin", - deps=(release,), - argv=_leaf_argv( - "audit", "-Tool", "dumpbin", "-Architecture", architecture, "-ModuleName", module - ), - resources=(_claim("cpu", 1), _claim("dumpbin", 1)), - writes=(dumpbin_output,), - outputs=(dumpbin_output,), - cacheable=False, - ) - ) - nodes.append( - _node( - binskim, - "audit-binskim", - deps=(release,), - argv=_leaf_argv( - "audit", "-Tool", "binskim", "-Architecture", architecture, "-ModuleName", module - ), - resources=(_claim("cpu", 1), _claim("memory-gib", 1), _claim("binskim", 1)), - writes=(binskim_output,), - outputs=(binskim_output,), - cacheable=False, - ) - ) - nodes.append( - _node( - stem, - "audit-module", - deps=(dumpbin, binskim), - argv=_leaf_argv( - "audit", "-Tool", "aggregate", "-Architecture", architecture, "-ModuleName", module - ), - resources=(_claim("cpu", 1),), - writes=(summary_output,), - outputs=(summary_output,), - cacheable=False, - ) - ) - - -def _add_packages( - nodes: list[dict[str, object]], architectures: Sequence[str], host_architecture: str, run_id: str -) -> None: - root = f".artifacts/package-runs/{run_id}" - init_output = f"{root}/init.json" - nodes.append( - _node( - "package-init", - "package-init", - deps=(), - argv=_leaf_argv("package", "-Phase", "init", "-RunId", run_id), - resources=(_claim("cpu", 1), _claim("package-init", 1)), - writes=(init_output,), - outputs=(init_output,), - cacheable=False, - ) - ) - evidence_inputs: list[str] = [] - runnable = {"x86", "x64"} if host_architecture == "x64" else {host_architecture} - for architecture in architectures: - for module in MODULES: - stem = f"package-{architecture}-{module}" - create_name = f"{stem}-create" - content_name = f"{stem}-content" - runtime_name = f"{stem}-runtime" - archive = f"{root}/archives/{architecture}/{module}.zip" - content_fragment = f"{root}/evidence/content/{architecture}/{module}.json" - runtime_fragment = f"{root}/evidence/runtime/{architecture}/{module}.json" - nodes.append( - _node( - create_name, - "package-create", - deps=("package-init", f"audit-{architecture}-{module}"), - argv=_leaf_argv( - "package", - "-Phase", - "create-module", - "-RunId", - run_id, - "-Architecture", - architecture, - "-ModuleName", - module, - ), - resources=(_claim("cpu", 1), _claim("archive-io", 1)), - writes=(f"{root}/stage/{architecture}/{module}", archive), - outputs=(archive,), - cacheable=False, - ) - ) - nodes.append( - _node( - content_name, - "package-content-smoke", - deps=(create_name,), - argv=_leaf_argv( - "package", - "-Phase", - "content-module", - "-RunId", - run_id, - "-Architecture", - architecture, - "-ModuleName", - module, - ), - resources=(_claim("cpu", 1), _claim("archive-io", 1)), - writes=(f"{root}/extracted/{architecture}/{module}", content_fragment), - outputs=(content_fragment,), - cacheable=False, - ) - ) - runtime_kind = "package-runtime-smoke" if architecture in runnable else "package-runtime-deferred" - runtime_deps = [content_name] - if runtime_kind == "package-runtime-smoke": - runtime_deps.append(_release_build_dependency(architecture, "tests")) - nodes.append( - _node( - runtime_name, - runtime_kind, - deps=runtime_deps, - argv=_leaf_argv( - "package", - "-Phase", - "runtime-module" if runtime_kind == "package-runtime-smoke" else "defer-runtime", - "-RunId", - run_id, - "-Architecture", - architecture, - "-ModuleName", - module, - ), - resources=(_claim("cpu", 1), _claim("runtime-smoke", 1)), - writes=(f"{root}/reports/{architecture}/{module}.xml", runtime_fragment), - outputs=(runtime_fragment,), - cacheable=False, - ) - ) - evidence_inputs.append(runtime_name) - symbols_name = f"symbols-{architecture}-create" - symbols_content_name = f"symbols-{architecture}-content" - symbols_archive = f"{root}/archives/{architecture}/symbols.zip" - symbols_fragment = f"{root}/evidence/symbols/{architecture}.json" - nodes.append( - _node( - symbols_name, - "symbols-create", - deps=( - "package-init", - *(_release_build_dependency(architecture, module) for module in MODULES), - ), - argv=_leaf_argv( - "package", "-Phase", "create-symbols", "-RunId", run_id, "-Architecture", architecture - ), - resources=(_claim("cpu", 1), _claim("archive-io", 1)), - writes=(f"{root}/stage/{architecture}/symbols", symbols_archive), - outputs=(symbols_archive,), - cacheable=False, - ) - ) - nodes.append( - _node( - symbols_content_name, - "symbols-content-smoke", - deps=(symbols_name,), - argv=_leaf_argv( - "package", "-Phase", "content-symbols", "-RunId", run_id, "-Architecture", architecture - ), - resources=(_claim("cpu", 1), _claim("archive-io", 1)), - writes=(f"{root}/extracted/{architecture}/symbols", symbols_fragment), - outputs=(symbols_fragment,), - cacheable=False, - ) - ) - evidence_inputs.append(symbols_content_name) - evidence = f"{root}/package-smoke-evidence.json" - nodes.append( - _node( - "package-evidence", - "package-evidence", - deps=evidence_inputs, - argv=_leaf_argv("package", "-Phase", "aggregate", "-RunId", run_id), - resources=(_claim("cpu", 1),), - writes=(evidence,), - outputs=(evidence,), - cacheable=False, - ) - ) - - -def build_dynamic_topology( - *, - architectures: Sequence[str] = ARCHITECTURES, - host_architecture: str = "x64", - leak_windows: int = 3, - run_id: str, -) -> dict[str, object]: - """Build deterministic records; execution is deliberately owned by leaf adapters.""" - - selected_architectures = tuple(architectures) - if ( - not selected_architectures - or len(set(selected_architectures)) != len(selected_architectures) - or any(architecture not in ARCHITECTURES for architecture in selected_architectures) - ): - raise DynamicTopologyError("architectures must be a non-empty unique subset of x86, x64, and arm64") - if host_architecture not in ARCHITECTURES: - raise DynamicTopologyError("unsupported host architecture") - if isinstance(leak_windows, bool) or not 3 <= leak_windows <= 10: - raise DynamicTopologyError("leak_windows must be from 3 to 10") - if not _RUN_ID.fullmatch(run_id): - raise DynamicTopologyError("run_id must be a safe 1-128 character path component") - - nodes: list[dict[str, object]] = [] - _add_fuzz(nodes, run_id) - _add_audits(nodes, selected_architectures) - _add_leaks(nodes, leak_windows) - _add_packages(nodes, selected_architectures, host_architecture, run_id) - external_nodes = { - _release_build_dependency("x64", "leak-probe"), - *(fuzz_build_node_name(target) for target in FUZZ_TARGETS), - *( - _release_build_dependency(architecture, module) - for architecture in selected_architectures - for module in MODULES - ), - *( - _release_build_dependency(architecture, "tests") - for architecture in selected_architectures - if architecture in ({"x86", "x64"} if host_architecture == "x64" else {host_architecture}) - ), - } - result = { - "schema": 2, - "external_nodes": sorted(external_nodes), - "nodes": sorted(nodes, key=lambda node: str(node["name"])), - } - validate_dynamic_topology(result) - return result - - -def _safe_relative_path(value: object) -> bool: - if not isinstance(value, str) or not value: - return False - path = PurePosixPath(value) - return not path.is_absolute() and ".." not in path.parts and "\\" not in value - - -def _paths_overlap(first: str, second: str) -> bool: - first_parts = tuple(part.casefold() for part in PurePosixPath(first).parts) - second_parts = tuple(part.casefold() for part in PurePosixPath(second).parts) - common = min(len(first_parts), len(second_parts)) - return first_parts[:common] == second_parts[:common] - - -def validate_dynamic_topology(topology: dict[str, object]) -> None: - """Reject unsafe paths, duplicate ownership, unknown dependencies, and cycles.""" - - if topology.get("schema") != 2: - raise DynamicTopologyError("dynamic topology schema must be 2") - nodes = topology.get("nodes") - external_nodes = topology.get("external_nodes") - if not isinstance(nodes, list) or not isinstance(external_nodes, list): - raise DynamicTopologyError("nodes and external_nodes must be lists") - if ( - not all(isinstance(name, str) and name for name in external_nodes) - or len(set(external_nodes)) != len(external_nodes) - ): - raise DynamicTopologyError("external_nodes must contain unique non-empty names") - names: set[str] = set() - write_owners: dict[str, str] = {} - output_owners: dict[str, str] = {} - for node in nodes: - if not isinstance(node, dict) or set(node) != _NODE_FIELDS: - raise DynamicTopologyError("every node must use the normalized schema-v2 fields") - name = node["name"] - if not isinstance(name, str) or not name or name in names: - raise DynamicTopologyError(f"duplicate or invalid node name: {name!r}") - names.add(name) - if not isinstance(node["cacheable"], bool): - raise DynamicTopologyError(f"cacheable must be boolean: {name}") - resources = node["resources"] - if not isinstance(resources, dict) or not resources: - raise DynamicTopologyError(f"node has no resource claims: {name}") - for resource_name, units in resources.items(): - if ( - not isinstance(resource_name, str) - or not resource_name - or isinstance(units, bool) - or not isinstance(units, int) - or units < 1 - ): - raise DynamicTopologyError(f"invalid resource claim: {name}") - for field, owners in (("writes", write_owners), ("outputs", output_owners)): - values = node[field] - if not isinstance(values, list): - raise DynamicTopologyError(f"{field} must be a list: {name}") - for value in values: - if not _safe_relative_path(value): - raise DynamicTopologyError(f"unsafe {field} path in {name}: {value!r}") - if value in owners: - raise DynamicTopologyError(f"{field} path has multiple owners: {value}") - if field == "writes": - for owned_path, owner in write_owners.items(): - if owner != name and _paths_overlap(owned_path, value): - raise DynamicTopologyError( - f"write paths overlap between {owner} and {name}: {owned_path}, {value}" - ) - owners[value] = name - if any( - not any(_paths_overlap(write, output) for write in node["writes"]) - for output in node["outputs"] - ): - raise DynamicTopologyError(f"every output must be below a declared write root: {name}") - external_names = set(external_nodes) - if names & external_names: - raise DynamicTopologyError("external_nodes must not collide with generated node names") - known = names | external_names - children: dict[str, list[str]] = {name: [] for name in names} - indegree = {name: 0 for name in names} - for node in nodes: - name = str(node["name"]) - deps = node["deps"] - run_after = node["run_after"] - argv = node["argv"] - inputs = node["inputs"] - fingerprint = node["fingerprint"] - if ( - not isinstance(deps, list) - or not isinstance(run_after, list) - or set(deps) & set(run_after) - or not isinstance(inputs, list) - or not isinstance(fingerprint, list) - or not isinstance(argv, list) - or not argv - or not all( - isinstance(argument, str) and argument for argument in argv - ) - or not all(isinstance(value, str) for value in (*inputs, *fingerprint)) - ): - raise DynamicTopologyError(f"invalid deps or argv: {name}") - for dependency in (*deps, *run_after): - if dependency not in known: - raise DynamicTopologyError(f"unknown dependency {dependency!r} in {name}") - if dependency in names: - children[dependency].append(name) - indegree[name] += 1 - ready = sorted(name for name, count in indegree.items() if count == 0) - visited = 0 - while ready: - current = ready.pop(0) - visited += 1 - for child in sorted(children[current]): - indegree[child] -= 1 - if indegree[child] == 0: - ready.append(child) - ready.sort() - if visited != len(names): - raise DynamicTopologyError("dynamic topology contains a dependency cycle") diff --git a/build/graph_driver.py b/build/graph_driver.py deleted file mode 100644 index a4f0469..0000000 --- a/build/graph_driver.py +++ /dev/null @@ -1,1063 +0,0 @@ -#!/usr/bin/env python3 -"""Stdlib-only DAG runner for local Observer build and verification work.""" - -from __future__ import annotations - -import argparse -from collections import deque -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait -from dataclasses import dataclass, replace -import glob -import hashlib -import heapq -from itertools import product -import json -import os -from pathlib import Path, PurePosixPath -import re -import subprocess -import sys -import threading -import time -from typing import Callable, Mapping, Sequence, TextIO -import uuid - - -SCHEMA = 2 -NAME = re.compile(r"^[A-Za-z0-9_.-]+$") -VARIABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -VARIABLE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") -GOOD = frozenset({"succeeded", "cached"}) -BAD = frozenset({"failed", "blocked", "cancelled"}) -FAILURE_POLICIES = frozenset({"continue", "fail-fast"}) - - -class GraphValidationError(ValueError): - pass - - -class CycleError(GraphValidationError): - pass - - -@dataclass(frozen=True) -class Node: - name: str - deps: tuple[str, ...] - run_after: tuple[str, ...] - resources: tuple[tuple[str, int], ...] - argv: tuple[str, ...] - inputs: tuple[str, ...] - writes: tuple[str, ...] - outputs: tuple[str, ...] - fingerprint_tokens: tuple[str, ...] - cacheable: bool - - @property - def prerequisites(self) -> tuple[str, ...]: - return self.deps + self.run_after - - -@dataclass(frozen=True) -class PlannedNode: - node: Node - fingerprint: str - - -@dataclass(frozen=True) -class NodeResult: - name: str - status: str - return_code: int | None - duration_seconds: float - log_path: Path - fingerprint: str - detail: str = "" - output_manifest: tuple[Mapping[str, str], ...] = () - - -@dataclass(frozen=True) -class RunSummary: - plan: tuple[PlannedNode, ...] - results: Mapping[str, NodeResult] - duration_seconds: float - - @property - def succeeded(self) -> bool: - return all(result.status in GOOD for result in self.results.values()) - - -def _strings(value: object, label: str) -> tuple[str, ...]: - if not isinstance(value, list) or any(not isinstance(item, str) for item in value): - raise GraphValidationError(f"{label} must be an array of strings") - return tuple(value) - - -def _name(value: object, label: str) -> str: - if not isinstance(value, str) or not NAME.fullmatch(value): - raise GraphValidationError(f"invalid {label}: {value!r}") - return value - - -def _variable_name(value: object, label: str) -> str: - if not isinstance(value, str) or not VARIABLE_NAME.fullmatch(value): - raise GraphValidationError(f"invalid {label}: {value!r}") - return value - - -def _positive_integer(value: object, label: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value < 1: - raise GraphValidationError(f"{label} must be a positive integer") - return value - - -def _relative(value: str, label: str, *, patterns: bool) -> None: - path = PurePosixPath(value) - if ( - not value - or "\\" in value - or path.is_absolute() - or ".." in path.parts - or path.parts[0].endswith(":") - or (not patterns and glob.has_magic(value)) - ): - raise GraphValidationError(f"unsafe {label}: {value!r}") - - -def _inside(root: Path, path: Path) -> bool: - try: - return os.path.commonpath((os.path.normcase(root), os.path.normcase(path))) == os.path.normcase(root) - except ValueError: - return False - - -def _path(root: Path, relative: str) -> Path: - result = root.joinpath(*PurePosixPath(relative).parts).resolve() - if not _inside(root, result): - raise GraphValidationError(f"path resolves outside the workspace: {relative!r}") - return result - - -def _sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as source: - while chunk := source.read(1024 * 1024): - digest.update(chunk) - return digest.hexdigest() - - -def _is_reparse_point(path: Path) -> bool: - try: - attributes = getattr(path.lstat(), "st_file_attributes", 0) - except OSError: - return False - return path.is_symlink() or bool(attributes & 0x400) - - -class _WorkspaceRunLock: - """One non-blocking OS lock serializes graph runs that share a workspace.""" - - def __init__(self, workspace: Path): - self.workspace = Path(workspace).resolve() - self.handle = None - - def __enter__(self): - lock_directory = _path(self.workspace, ".artifacts/graph") - lock_directory.mkdir(parents=True, exist_ok=True) - lock_path = lock_directory / ".workspace-run.lock" - if _is_reparse_point(lock_path): - raise GraphValidationError(f"workspace run lock is a reparse point: {lock_path}") - handle = lock_path.open("a+b", buffering=0) - if lock_path.stat().st_size == 0: - handle.write(b"\0") - handle.seek(0) - try: - if os.name == "nt": - import msvcrt - - msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError as error: - handle.close() - raise GraphValidationError( - f"a graph run is already running in workspace {self.workspace}" - ) from error - self.handle = handle - return self - - def __exit__(self, exception_type, _exception, _traceback): - if self.handle is None: - return False - unlock_error = None - try: - self.handle.seek(0) - if os.name == "nt": - import msvcrt - - msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) - except OSError as error: - unlock_error = error - finally: - self.handle.close() - self.handle = None - if unlock_error is not None and exception_type is None: - raise GraphValidationError("failed to release the workspace graph run lock") from unlock_error - return False - - -class Graph: - def __init__(self, name, workspace, resources, nodes, targets, failure_policy): - self.name = name - self.workspace = workspace - self.resources = resources - self.nodes = nodes - self.targets = targets - self.failure_policy = failure_policy - - @classmethod - def from_mapping(cls, name: str, mapping: object, workspace: Path) -> "Graph": - graph_name = _name(name, "graph name") - root = Path(workspace).resolve() - if not isinstance(mapping, dict) or not root.is_dir(): - raise GraphValidationError("graph mapping and existing workspace are required") - if "pools" in mapping: - raise GraphValidationError("legacy pools are not accepted by schema 2") - allowed_graph_keys = {"failure_policy", "resources", "targets", "nodes"} - unexpected_graph_keys = set(mapping) - allowed_graph_keys - if unexpected_graph_keys: - raise GraphValidationError(f"unknown graph fields: {sorted(unexpected_graph_keys)}") - - raw_resources = mapping.get("resources") - if not isinstance(raw_resources, dict) or not raw_resources: - raise GraphValidationError("named resources are required") - resources: dict[str, int] = {} - for key, capacity in raw_resources.items(): - resource = _name(key, "resource name") - resources[resource] = _positive_integer(capacity, f"capacity for {resource}") - - failure_policy = mapping.get("failure_policy", "continue") - if failure_policy not in FAILURE_POLICIES: - raise GraphValidationError("failure_policy must be continue or fail-fast") - - raw_nodes = mapping.get("nodes") - if not isinstance(raw_nodes, list) or not raw_nodes: - raise GraphValidationError("nodes are required") - nodes: dict[str, Node] = {} - allowed_node_keys = { - "name", - "deps", - "run_after", - "resources", - "argv", - "inputs", - "writes", - "outputs", - "fingerprint", - "cacheable", - } - for raw in raw_nodes: - if not isinstance(raw, dict): - raise GraphValidationError("every node must be an object") - if "pool" in raw: - raise GraphValidationError("legacy pool is not accepted by schema 2") - unexpected = set(raw) - allowed_node_keys - if unexpected: - raise GraphValidationError(f"unknown node fields: {sorted(unexpected)}") - current = _name(raw.get("name"), "node name") - if current in nodes: - raise GraphValidationError(f"duplicate node: {current}") - - deps = _strings(raw.get("deps"), f"deps for {current}") - run_after = _strings(raw.get("run_after"), f"run_after for {current}") - argv = _strings(raw.get("argv"), f"argv for {current}") - inputs = _strings(raw.get("inputs"), f"inputs for {current}") - writes = _strings(raw.get("writes"), f"writes for {current}") - outputs = _strings(raw.get("outputs"), f"outputs for {current}") - tokens = _strings(raw.get("fingerprint"), f"fingerprint for {current}") - cacheable = raw.get("cacheable") - raw_demands = raw.get("resources") - if not argv or any(not arg for arg in argv): - raise GraphValidationError(f"non-empty argv is required for {current}") - if len(deps) != len(set(deps)) or len(run_after) != len(set(run_after)): - raise GraphValidationError(f"duplicate prerequisite for {current}") - overlap = set(deps) & set(run_after) - if overlap: - raise GraphValidationError( - f"prerequisite cannot be both deps and run_after for {current}: {sorted(overlap)}" - ) - if not isinstance(raw_demands, dict) or not raw_demands: - raise GraphValidationError(f"resources for {current} must be a non-empty object") - demands: dict[str, int] = {} - for key, raw_demand in raw_demands.items(): - resource = _name(key, f"resource for {current}") - if resource not in resources: - raise GraphValidationError(f"unknown resource {resource!r} for {current}") - demand = _positive_integer(raw_demand, f"demand for {current}/{resource}") - if demand > resources[resource]: - raise GraphValidationError( - f"resource demand for {current}/{resource} exceeds capacity {resources[resource]}" - ) - demands[resource] = demand - if not isinstance(cacheable, bool): - raise GraphValidationError(f"cacheable must be boolean for {current}") - if cacheable and not outputs: - raise GraphValidationError(f"cacheable node {current!r} requires explicit outputs") - if len(writes) != len(set(writes)) or len(outputs) != len(set(outputs)): - raise GraphValidationError(f"duplicate write or output path for {current}") - for item in inputs: - _relative(item, f"input for {current}", patterns=True) - for item in writes: - _relative(item, f"write for {current}", patterns=False) - _path(root, item) - for item in outputs: - _relative(item, f"output for {current}", patterns=False) - output_path = _path(root, item) - if not any(_inside(_path(root, write), output_path) for write in writes): - raise GraphValidationError( - f"output for {current} is not below a declared write root: {item!r}" - ) - nodes[current] = Node( - current, - tuple(sorted(deps)), - tuple(sorted(run_after)), - tuple(sorted(demands.items())), - argv, - tuple(sorted(inputs)), - tuple(sorted(writes)), - tuple(sorted(outputs)), - tuple(sorted(tokens)), - cacheable, - ) - - for current in nodes.values(): - unknown = set(current.prerequisites) - nodes.keys() - if unknown: - raise GraphValidationError(f"unknown prerequisites for {current.name}: {sorted(unknown)}") - targets = _strings(mapping.get("targets"), "targets") - if not targets or len(targets) != len(set(targets)) or set(targets) - nodes.keys(): - raise GraphValidationError("targets must be unique, non-empty, and known") - return cls(graph_name, root, resources, nodes, tuple(sorted(targets)), failure_policy) - - def _closure(self, targets: Sequence[str]) -> set[str]: - if set(targets) - self.nodes.keys(): - raise GraphValidationError("unknown target") - result, pending = set(), list(targets) - while pending: - current = pending.pop() - if current not in result: - result.add(current) - pending.extend(self.nodes[current].prerequisites) - return result - - def _cycle(self, remaining: set[str]) -> list[str]: - state: dict[str, int] = {} - stack: list[str] = [] - positions: dict[str, int] = {} - - def visit(current): - state[current], positions[current] = 1, len(stack) - stack.append(current) - for dependency in self.nodes[current].prerequisites: - if dependency not in remaining: - continue - if not state.get(dependency): - found = visit(dependency) - if found: - return found - elif state[dependency] == 1: - return stack[positions[dependency] :] + [dependency] - stack.pop() - positions.pop(current) - state[current] = 2 - return None - - for current in sorted(remaining): - if not state.get(current) and (found := visit(current)): - return found - return sorted(remaining) - - def _order(self, targets: Sequence[str]) -> list[str]: - closure = self._closure(targets) - indegree = { - name: sum(dependency in closure for dependency in self.nodes[name].prerequisites) - for name in closure - } - children = {name: [] for name in closure} - for name in closure: - for dependency in self.nodes[name].prerequisites: - if dependency in closure: - children[dependency].append(name) - ready = [name for name, count in indegree.items() if not count] - heapq.heapify(ready) - ordered: list[str] = [] - while ready: - current = heapq.heappop(ready) - ordered.append(current) - for child in sorted(children[current]): - indegree[child] -= 1 - if not indegree[child]: - heapq.heappush(ready, child) - if len(ordered) != len(closure): - cycle = self._cycle(closure - set(ordered)) - raise CycleError(f"cycle detected: {' -> '.join(cycle)}") - return ordered - - def _validate_write_conflicts(self, ordered: Sequence[str]) -> None: - ancestors: dict[str, set[str]] = {} - for name in ordered: - current: set[str] = set() - for dependency in self.nodes[name].prerequisites: - if dependency in ancestors: - current.add(dependency) - current.update(ancestors[dependency]) - ancestors[name] = current - - for left_index, left_name in enumerate(ordered): - left = self.nodes[left_name] - left_resources = dict(left.resources) - for right_name in ordered[left_index + 1 :]: - right = self.nodes[right_name] - if left_name in ancestors[right_name] or right_name in ancestors[left_name]: - continue - right_resources = dict(right.resources) - has_lock = any( - self.resources[resource] == 1 and resource in right_resources - for resource in left_resources - ) - if has_lock: - continue - for left_write in left.writes: - left_path = _path(self.workspace, left_write) - for right_write in right.writes: - right_path = _path(self.workspace, right_write) - if _inside(left_path, right_path) or _inside(right_path, left_path): - raise GraphValidationError( - f"overlapping writes require dependency order or a shared capacity-one resource: " - f"{left_name}:{left_write!r}, {right_name}:{right_write!r}" - ) - - def _input_records( - self, - node: Node, - digests: dict[Path, str], - ) -> list[tuple[str, str]]: - records: dict[str, str] = {} - missing: list[tuple[str, str]] = [] - for pattern in node.inputs: - matches = sorted(path for path in self.workspace.glob(pattern) if path.is_file()) - if not matches: - missing.append((pattern, "missing")) - for match in matches: - resolved = match.resolve() - if not _inside(self.workspace, resolved): - raise GraphValidationError(f"input escapes workspace: {match}") - if resolved not in digests: - digests[resolved] = _sha256_file(resolved) - records[match.relative_to(self.workspace).as_posix()] = digests[resolved] - return sorted(records.items()) + missing - - def _planned_node( - self, - name: str, - fingerprints: Mapping[str, str], - digests: dict[Path, str], - ) -> PlannedNode: - node = self.nodes[name] - payload = { - "schema": SCHEMA, - "name": name, - "argv": node.argv, - "resources": dict(node.resources), - "cacheable": node.cacheable, - "writes": node.writes, - "outputs": node.outputs, - "tokens": node.fingerprint_tokens, - "deps": {dep: fingerprints[dep] for dep in node.deps}, - "run_after": {dep: fingerprints[dep] for dep in node.run_after}, - "inputs": self._input_records(node, digests), - } - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() - return PlannedNode(node, hashlib.sha256(encoded).hexdigest()) - - def execution_order(self, targets: Sequence[str] | None = None) -> tuple[str, ...]: - ordered = self._order(targets or self.targets) - self._validate_write_conflicts(ordered) - return tuple(ordered) - - def plan(self, targets: Sequence[str] | None = None) -> tuple[PlannedNode, ...]: - fingerprints: dict[str, str] = {} - digests: dict[Path, str] = {} - result: list[PlannedNode] = [] - for name in self.execution_order(targets): - item = self._planned_node(name, fingerprints, digests) - fingerprints[name] = item.fingerprint - result.append(item) - return tuple(result) - - -Executor = Callable[[Node, Path, TextIO, threading.Event], int] - - -def execute_subprocess( - node: Node, - workspace: Path, - stream: TextIO, - _cancellation: threading.Event, -) -> int: - return subprocess.run( - list(node.argv), - cwd=workspace, - stdin=subprocess.DEVNULL, - stdout=stream, - stderr=subprocess.STDOUT, - check=False, - shell=False, - ).returncode - - -class GraphRunner: - def __init__( - self, - graph: Graph, - logs_dir: Path, - state_dir: Path, - *, - max_workers: int | None = None, - executor: Executor = execute_subprocess, - ): - self.graph, self.executor = graph, executor - self.logs_root, self.state_dir = Path(logs_dir).resolve(), Path(state_dir).resolve() - if not _inside(graph.workspace, self.logs_root) or not _inside(graph.workspace, self.state_dir): - raise GraphValidationError("log and state paths must stay inside the workspace") - if max_workers is None: - self.max_workers = sum(graph.resources.values()) - else: - self.max_workers = max_workers - if isinstance(self.max_workers, bool) or not isinstance(self.max_workers, int) or self.max_workers < 1: - raise GraphValidationError("max_workers must be positive") - - def _new_logs_dir(self) -> Path: - self.logs_root.mkdir(parents=True, exist_ok=True) - for _ in range(10): - run_id = f"run-{time.time_ns()}-{os.getpid()}-{uuid.uuid4().hex}" - target = self.logs_root / run_id - try: - target.mkdir() - except FileExistsError: - continue - return target - raise RuntimeError("could not allocate a unique graph log directory") - - @staticmethod - def _log(logs_dir: Path, node: Node) -> Path: - return logs_dir / f"{node.name}.log" - - def _state(self, node: Node) -> Path: - return self.state_dir / f"{node.name}.json" - - def _tree_digest(self, root: Path) -> str: - digest = hashlib.sha256() - for current, directories, files in os.walk(root, topdown=True, followlinks=False): - current_path = Path(current) - directories.sort() - files.sort() - for name in directories: - entry = current_path / name - if _is_reparse_point(entry): - raise GraphValidationError(f"output tree contains a reparse point: {entry}") - relative = entry.relative_to(root).as_posix() - digest.update(f"D\0{relative}\0".encode()) - for name in files: - entry = current_path / name - if _is_reparse_point(entry): - raise GraphValidationError(f"output tree contains a reparse point: {entry}") - relative = entry.relative_to(root).as_posix() - digest.update(f"F\0{relative}\0{_sha256_file(entry)}\0".encode()) - return digest.hexdigest() - - def _output_manifest(self, node: Node) -> list[dict[str, str]]: - records: list[dict[str, str]] = [] - for relative in node.outputs: - raw_path = self.graph.workspace.joinpath(*PurePosixPath(relative).parts) - if _is_reparse_point(raw_path): - raise GraphValidationError(f"output is a reparse point: {relative!r}") - path = _path(self.graph.workspace, relative) - if path.is_file(): - records.append({"kind": "file", "path": relative, "sha256": _sha256_file(path)}) - elif path.is_dir(): - records.append({"kind": "directory", "path": relative, "sha256": self._tree_digest(path)}) - else: - raise FileNotFoundError(relative) - return records - - def _cached(self, item: PlannedNode) -> bool: - if not item.node.cacheable: - return False - try: - state = json.loads(self._state(item.node).read_text(encoding="utf-8")) - manifest = self._output_manifest(item.node) - except (OSError, json.JSONDecodeError, GraphValidationError): - return False - return state == { - "fingerprint": item.fingerprint, - "outputs": manifest, - "schema": SCHEMA, - } - - def _invalidate(self, item: PlannedNode) -> None: - if item.node.cacheable: - self._state(item.node).unlink(missing_ok=True) - - def _save( - self, - item: PlannedNode, - manifest: Sequence[Mapping[str, str]], - ) -> None: - target = self._state(item.node) - temporary = target.with_name(f".{target.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") - try: - temporary.write_text( - json.dumps( - { - "fingerprint": item.fingerprint, - "outputs": list(manifest), - "schema": SCHEMA, - }, - sort_keys=True, - ), - encoding="utf-8", - ) - os.replace(temporary, target) - finally: - temporary.unlink(missing_ok=True) - - def _record(self, item: PlannedNode, logs_dir: Path, status: str, detail: str = "") -> NodeResult: - log = self._log(logs_dir, item.node) - log.write_text(f"[{status}] {detail}\n", encoding="utf-8") - return NodeResult(item.node.name, status, None, 0.0, log, item.fingerprint, detail) - - def _execute( - self, - item: PlannedNode, - logs_dir: Path, - cancellation: threading.Event, - ) -> NodeResult: - started, node, log = time.perf_counter(), item.node, self._log(logs_dir, item.node) - code: int | None = None - detail = "" - manifest: tuple[Mapping[str, str], ...] = () - try: - with log.open("w", encoding="utf-8") as stream: - stream.write(f"fingerprint={item.fingerprint}\nargv={json.dumps(node.argv)}\n") - stream.flush() - code = self.executor(node, self.graph.workspace, stream, cancellation) - if isinstance(code, bool) or not isinstance(code, int): - raise TypeError("executor must return an integer") - except Exception as error: - detail = f"executor error: {type(error).__name__}: {error}" - with log.open("a", encoding="utf-8") as stream: - stream.write(detail + "\n") - if code == 0 and not detail and node.cacheable: - try: - manifest = tuple(self._output_manifest(node)) - except Exception as error: - detail = f"output manifest error: {type(error).__name__}: {error}" - with log.open("a", encoding="utf-8") as stream: - stream.write(detail + "\n") - if cancellation.is_set(): - status = "cancelled" - detail = detail or "cancelled after fail-fast failure" - else: - status = "succeeded" if code == 0 and not detail else "failed" - return NodeResult( - node.name, - status, - code, - time.perf_counter() - started, - log, - item.fingerprint, - detail, - manifest, - ) - - def run(self, targets: Sequence[str] | None = None) -> RunSummary: - with _WorkspaceRunLock(self.graph.workspace): - return self._run(targets) - - def _run(self, targets: Sequence[str] | None = None) -> RunSummary: - started = time.perf_counter() - ordered = self.graph.execution_order(targets) - order_index = {name: index for index, name in enumerate(ordered)} - items: dict[str, PlannedNode] = {} - fingerprints: dict[str, str] = {} - digests: dict[Path, str] = {} - results: dict[str, NodeResult] = {} - used = {resource: 0 for resource in self.graph.resources} - running: dict[object, PlannedNode] = {} - checking: dict[object, PlannedNode] = {} - remaining = { - name: len(self.graph.nodes[name].prerequisites) - for name in ordered - } - children = {name: [] for name in ordered} - for name in ordered: - for dependency in self.graph.nodes[name].prerequisites: - children[dependency].append(name) - for dependents in children.values(): - dependents.sort(key=order_index.__getitem__) - ready = deque((name, False) for name in ordered if remaining[name] == 0) - logs_dir = self._new_logs_dir() - self.state_dir.mkdir(parents=True, exist_ok=True) - aborting = False - cancellation = threading.Event() - - def ensure_item(name: str) -> PlannedNode: - if name not in items: - item = self.graph._planned_node(name, fingerprints, digests) - items[name] = item - fingerprints[name] = item.fingerprint - return items[name] - - def placeholder(name: str) -> PlannedNode: - return items.get(name, PlannedNode(self.graph.nodes[name], "")) - - def finish(name: str, result: NodeResult) -> None: - results[name] = result - for child in children[name]: - remaining[child] -= 1 - if remaining[child] == 0 and child not in results: - ready.append((child, False)) - - def fits(item: PlannedNode) -> bool: - if len(running) + len(checking) >= self.max_workers: - return False - return all( - used[resource] + demand <= self.graph.resources[resource] - for resource, demand in item.node.resources - ) - - def reserve(item: PlannedNode, direction: int) -> None: - for resource, demand in item.node.resources: - used[resource] += direction * demand - - def invalidate_digests(node: Node) -> None: - roots = tuple(_path(self.graph.workspace, write) for write in node.writes) - for cached_path in tuple(digests): - if any(_inside(root, cached_path) for root in roots): - digests.pop(cached_path) - - def state_failure(item: PlannedNode, result: NodeResult, error: Exception) -> NodeResult: - detail = f"cache state error: {type(error).__name__}: {error}" - try: - self._invalidate(item) - except OSError as invalidation_error: - detail += f"; state invalidation error: {invalidation_error}" - with result.log_path.open("a", encoding="utf-8") as stream: - stream.write(detail + "\n") - return replace(result, status="failed", detail=detail, output_manifest=()) - - with ( - ThreadPoolExecutor(max_workers=self.max_workers) as workers, - ThreadPoolExecutor(max_workers=self.max_workers) as cache_workers, - ): - while ready or running or checking: - dispatch_progress = False - for _ in range(len(ready)): - name, cache_checked = ready.popleft() - item = ensure_item(name) - if aborting: - finish(name, self._record(item, logs_dir, "cancelled", "fail-fast policy")) - dispatch_progress = True - continue - failed = sorted( - dependency - for dependency in item.node.deps - if results[dependency].status in BAD - ) - if failed: - finish( - name, - self._record(item, logs_dir, "blocked", f"dependency failure: {', '.join(failed)}"), - ) - dispatch_progress = True - continue - prerequisites_cached = all( - results[dependency].status == "cached" - for dependency in item.node.prerequisites - ) - if prerequisites_cached and item.node.cacheable and not cache_checked: - if fits(item): - reserve(item, 1) - checking[cache_workers.submit(self._cached, item)] = item - dispatch_progress = True - else: - ready.append((name, cache_checked)) - continue - if fits(item): - try: - self._invalidate(item) - except OSError as error: - finish( - name, - self._record( - item, - logs_dir, - "failed", - f"cache state invalidation error: {type(error).__name__}: {error}", - ), - ) - dispatch_progress = True - continue - reserve(item, 1) - future = workers.submit(self._execute, item, logs_dir, cancellation) - running[future] = item - dispatch_progress = True - else: - ready.append((name, cache_checked)) - - if running or checking: - done, _ = wait(tuple(running) + tuple(checking), return_when=FIRST_COMPLETED) - for future in sorted( - (current for current in done if current in checking), - key=lambda current: order_index[checking[current].node.name], - ): - item = checking.pop(future) - reserve(item, -1) - if item.node.name in results: - continue - if future.result(): - finish( - item.node.name, - self._record(item, logs_dir, "cached", "fingerprint and outputs match"), - ) - else: - ready.append((item.node.name, True)) - failed_now = False - for future in sorted( - (current for current in done if current in running), - key=lambda current: order_index[running[current].node.name], - ): - item = running.pop(future) - reserve(item, -1) - result = future.result() - invalidate_digests(item.node) - if result.status == "succeeded" and item.node.cacheable: - try: - self._save(item, result.output_manifest) - except Exception as error: - result = state_failure(item, result, error) - finish(item.node.name, result) - failed_now = failed_now or result.status == "failed" - if failed_now and self.graph.failure_policy == "fail-fast": - aborting = True - cancellation.set() - running_names = {item.node.name for item in running.values()} - for name in ordered: - if name not in results and name not in running_names: - results[name] = self._record( - placeholder(name), - logs_dir, - "cancelled", - "fail-fast policy", - ) - ready.clear() - elif ready and not dispatch_progress: - raise RuntimeError("validated scheduler made no progress") - - for name in ordered: - item = ensure_item(name) - if results[name].fingerprint != item.fingerprint: - results[name] = replace(results[name], fingerprint=item.fingerprint) - plan = tuple(items[name] for name in ordered) - return RunSummary(plan, results, time.perf_counter() - started) - - -def _expand(value, variables): - if isinstance(value, str): - def replace(match): - try: - return variables[match.group(1)] - except KeyError as error: - raise GraphValidationError(f"unknown variable: {error.args[0]}") from error - - return VARIABLE.sub(replace, value) - if isinstance(value, list): - return [_expand(item, variables) for item in value] - if isinstance(value, dict): - return {key: _expand(item, variables) for key, item in value.items()} - return value - - -def _expand_nodes(raw_nodes: object, variables: Mapping[str, str]) -> list[object]: - if not isinstance(raw_nodes, list): - raise GraphValidationError("nodes are required") - expanded_nodes: list[object] = [] - for raw_node in raw_nodes: - if not isinstance(raw_node, dict): - raise GraphValidationError("every node must be an object") - matrix = raw_node.get("matrix") - if matrix is None: - expanded_nodes.append(_expand(raw_node, variables)) - continue - if not isinstance(matrix, dict) or set(matrix) != {"axes", "exclude"}: - raise GraphValidationError("matrix must contain exactly axes and exclude") - raw_axes = matrix["axes"] - if not isinstance(raw_axes, dict) or not raw_axes: - raise GraphValidationError("matrix axes must be a non-empty object") - axes: dict[str, tuple[str, ...]] = {} - for raw_name, raw_values in raw_axes.items(): - axis = _variable_name(raw_name, "matrix axis") - if axis in variables: - raise GraphValidationError(f"matrix axis {axis!r} collides with graph variable") - values = _strings(_expand(raw_values, variables), f"values for matrix axis {axis}") - if not values or any(not value for value in values) or len(values) != len(set(values)): - raise GraphValidationError(f"matrix axis {axis!r} needs unique non-empty values") - axes[axis] = tuple(sorted(values)) - - raw_excludes = matrix["exclude"] - if not isinstance(raw_excludes, list) or any(not isinstance(item, dict) for item in raw_excludes): - raise GraphValidationError("matrix exclude must be an array of exact assignments") - axis_names = tuple(sorted(axes)) - excludes: set[tuple[str, ...]] = set() - for raw_exclude in raw_excludes: - expanded_exclude = _expand(raw_exclude, variables) - if set(expanded_exclude) != set(axis_names): - raise GraphValidationError("matrix exclude must be an exact assignment of every axis") - assignment: list[str] = [] - for axis in axis_names: - value = expanded_exclude[axis] - if not isinstance(value, str) or value not in axes[axis]: - raise GraphValidationError("matrix exclude must be an exact known assignment") - assignment.append(value) - key = tuple(assignment) - if key in excludes: - raise GraphValidationError("duplicate matrix exclude assignment") - excludes.add(key) - - template = dict(raw_node) - template.pop("matrix") - for values in product(*(axes[axis] for axis in axis_names)): - if values in excludes: - continue - matrix_variables = {**variables, **dict(zip(axis_names, values, strict=True))} - expanded_nodes.append(_expand(template, matrix_variables)) - return expanded_nodes - - -def load_graph_profile(profile_path, graph_name, workspace, overrides=None): - try: - profile = json.loads(Path(profile_path).read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise GraphValidationError(f"cannot read profile: {error}") from error - if not isinstance(profile, dict) or profile.get("schema") != SCHEMA: - raise GraphValidationError(f"profile schema must be {SCHEMA}") - selected = graph_name or profile.get("default_graph") - try: - raw = profile["graphs"][selected] - except (KeyError, TypeError) as error: - raise GraphValidationError(f"unknown graph: {selected!r}") from error - if not isinstance(raw, dict): - raise GraphValidationError("selected graph must be an object") - declared_variables = raw.get("variables", {}) - if not isinstance(declared_variables, dict) or any( - not isinstance(value, str) for value in declared_variables.values() - ): - raise GraphValidationError("variables must map names to strings") - for key in declared_variables: - _variable_name(key, "variable name") - if "python" in declared_variables: - raise GraphValidationError("python is a reserved variable") - variables = {"python": str(Path(sys.executable).resolve()), **declared_variables} - for key, value in (overrides or {}).items(): - if key not in declared_variables: - raise GraphValidationError(f"undeclared override: {key}") - variables[key] = value - - normalized = { - key: _expand(value, variables) - for key, value in raw.items() - if key not in {"variables", "nodes"} - } - normalized["nodes"] = _expand_nodes(raw.get("nodes"), variables) - return Graph.from_mapping(selected, normalized, Path(workspace)) - - -def plan_as_json(plan): - rows = [ - { - "name": item.node.name, - "deps": item.node.deps, - "run_after": item.node.run_after, - "resources": dict(item.node.resources), - "argv": item.node.argv, - "inputs": item.node.inputs, - "writes": item.node.writes, - "outputs": item.node.outputs, - "cacheable": item.node.cacheable, - "fingerprint": item.fingerprint, - } - for item in plan - ] - return json.dumps(rows, indent=2, sort_keys=True) + "\n" - - -def _parser(): - parser = argparse.ArgumentParser(description=__doc__) - commands = parser.add_subparsers(dest="command", required=True) - for command in ("plan", "run"): - child = commands.add_parser(command) - child.add_argument("--profile", type=Path, default=Path(__file__).with_name("graph_profiles.json")) - child.add_argument("--graph") - child.add_argument("--workspace", type=Path, default=Path(__file__).resolve().parent.parent) - child.add_argument("--set", action="append", default=[], metavar="NAME=VALUE") - child.add_argument("--target", action="append", default=[]) - commands.choices["plan"].add_argument("--json", action="store_true") - commands.choices["run"].add_argument("--jobs", type=int) - return parser - - -def main(argv=None): - args = _parser().parse_args(argv) - try: - overrides = {} - for item in args.set: - name, separator, value = item.partition("=") - if not separator or not name or name in overrides: - raise GraphValidationError(f"invalid --set: {item!r}") - overrides[name] = value - graph = load_graph_profile(args.profile, args.graph, args.workspace, overrides) - targets = args.target or None - if args.command == "plan": - plan = graph.plan(targets) - if args.json: - print(plan_as_json(plan), end="") - else: - for index, item in enumerate(plan, 1): - policy = "cacheable" if item.node.cacheable else "always-run" - resources = ",".join(f"{name}={demand}" for name, demand in item.node.resources) - print( - f"{index:02d} {item.node.name} resources={resources} " - f"{policy} fingerprint={item.fingerprint[:16]}" - ) - return 0 - base = graph.workspace / ".artifacts" / "graph" / graph.name - summary = GraphRunner(graph, base / "logs", base / "state", max_workers=args.jobs).run(targets) - for item in summary.plan: - result = summary.results[item.node.name] - print(f"{result.status:9} {result.name} {result.duration_seconds:.3f}s log={result.log_path}") - print(f"total {summary.duration_seconds:.3f}s") - return 0 if summary.succeeded else 1 - except GraphValidationError as error: - print(f"graph error: {error}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/build/graph_profiles.json b/build/graph_profiles.json deleted file mode 100644 index ca92e5b..0000000 --- a/build/graph_profiles.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "schema": 1, - "default_graph": "observer-verify-shadow", - "graphs": { - "observer-verify-shadow": { - "variables": { - "arch": "x64", - "fuzz_seconds": "60" - }, - "pools": { - "normal-x64": 3, - "asan-x64": 1 - }, - "targets": ["package"], - "nodes": [ - { - "name": "doctor", - "deps": [], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "doctor"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=doctor"], - "cacheable": false - }, - { - "name": "restore", - "deps": ["doctor"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "restore", "-Arch", "{arch}", "-RestoreFlavor", "all"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "vcpkg.json", "vcpkg-configuration.json", "vcpkg/triplets/*.cmake"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v2", "flavors=default+asan", "leaf=restore"], - "cacheable": false - }, - { - "name": "source-checks", - "deps": ["restore"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "source-checks", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": [".clang-format", ".clang-tidy", "build.ps1", "build/**/*.ps1", "build/**/*.props", "src/**/*.cpp", "src/**/*.h"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=source-checks"], - "cacheable": false - }, - { - "name": "test-debug", - "deps": ["source-checks"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test", "-Arch", "{arch}", "-Config", "Debug", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["configuration=Debug", "contract=observer-shadow-v1", "leaf=test"], - "cacheable": false - }, - { - "name": "test-release", - "deps": ["source-checks"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test", "-Arch", "{arch}", "-Config", "Release", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["configuration=Release", "contract=observer-shadow-v1", "leaf=test"], - "cacheable": false - }, - { - "name": "compiler-analysis", - "deps": ["test-debug"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "compiler-analysis", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": [".clang-tidy", "build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=compiler-analysis"], - "cacheable": false - }, - { - "name": "coverage", - "deps": ["source-checks"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-coverage", "-Arch", "{arch}", "-CoverageThreshold", "100", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "coverage-threshold=100", "leaf=test-coverage"], - "cacheable": false - }, - { - "name": "asan", - "deps": ["source-checks"], - "pool": "asan-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-asan", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=test-asan"], - "cacheable": false - }, - { - "name": "ubsan", - "deps": ["source-checks"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-ubsan", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=test-ubsan"], - "cacheable": false - }, - { - "name": "leaks", - "deps": ["compiler-analysis", "test-release", "coverage", "ubsan", "fuzz"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "test-leaks", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=test-leaks"], - "cacheable": false - }, - { - "name": "fuzz", - "deps": ["asan"], - "pool": "asan-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "fuzz", "-Arch", "{arch}", "-FuzzTarget", "all", "-FuzzSeconds", "{fuzz_seconds}", "-SkipDependencyRestore"], - "inputs": ["build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "fuzz-target=all", "leaf=fuzz"], - "cacheable": false - }, - { - "name": "package", - "deps": ["leaks"], - "pool": "normal-x64", - "argv": ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", "package", "-Arch", "{arch}", "-SkipDependencyRestore"], - "inputs": ["LICENSE.txt", "build.ps1", "build/**/*.ps1", "build/**/*.props", "build/**/*.proj", "build/**/*.vcxproj", "licenses/*.txt", "src/**/*", "vcpkg.json"], - "outputs": [], - "fingerprint": ["contract=observer-shadow-v1", "leaf=package"], - "cacheable": false - } - ] - }, - "synthetic-cache-benchmark": { - "variables": { - "run_id": "default" - }, - "pools": { - "cpu": 2 - }, - "targets": ["gate"], - "nodes": [ - { - "name": "prepare", - "deps": [], - "pool": "cpu", - "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.05); p=Path('.artifacts/graph-benchmark/{run_id}/prepare.txt'); p.parent.mkdir(parents=True, exist_ok=True); p.write_text('prepared', encoding='utf-8')"], - "inputs": ["build/graph_driver.py"], - "outputs": [".artifacts/graph-benchmark/{run_id}/prepare.txt"], - "fingerprint": ["benchmark=prepare-v1"], - "cacheable": true - }, - { - "name": "compile-a", - "deps": ["prepare"], - "pool": "cpu", - "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.08); Path('.artifacts/graph-benchmark/{run_id}/compile-a.txt').write_text('a', encoding='utf-8')"], - "inputs": ["build/graph_driver.py"], - "outputs": [".artifacts/graph-benchmark/{run_id}/compile-a.txt"], - "fingerprint": ["benchmark=compile-a-v1"], - "cacheable": true - }, - { - "name": "compile-b", - "deps": ["prepare"], - "pool": "cpu", - "argv": ["{python}", "-c", "from pathlib import Path; import time; time.sleep(0.08); Path('.artifacts/graph-benchmark/{run_id}/compile-b.txt').write_text('b', encoding='utf-8')"], - "inputs": ["build/graph_driver.py"], - "outputs": [".artifacts/graph-benchmark/{run_id}/compile-b.txt"], - "fingerprint": ["benchmark=compile-b-v1"], - "cacheable": true - }, - { - "name": "gate", - "deps": ["compile-a", "compile-b"], - "pool": "cpu", - "argv": ["{python}", "-c", "import time; time.sleep(0.02); print('non-cacheable gate ran')"], - "inputs": ["build/graph_driver.py"], - "outputs": [], - "fingerprint": ["benchmark=gate-v1"], - "cacheable": false - } - ] - } - } -} diff --git a/build/ixdag/__init__.py b/build/ixdag/__init__.py deleted file mode 100644 index b99dba0..0000000 --- a/build/ixdag/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Small typed build-graph model inspired by pg83/ix.""" - diff --git a/build/ixdag/execute.py b/build/ixdag/execute.py deleted file mode 100644 index 43a38a5..0000000 --- a/build/ixdag/execute.py +++ /dev/null @@ -1,366 +0,0 @@ -# Copyright (c) pg83 contributors -# SPDX-License-Identifier: MIT -# -# Derived from pg83/ix core/execute.py (MIT): -# https://github.com/pg83/ix/blob/main/core/execute.py -# -# The adaptation preserves IX's demand-driven asyncio visitor, per-node lock, -# named semaphore pools, immutable output directories, completion marker, and -# trash-before-retry behavior. Windows changes are intentionally confined to -# paths and process creation. - -"""Small, Windows-capable, IX-derived content-addressed DAG executor. - -The default runner waits for and can terminate only its direct child. Reliable -Windows process-tree cancellation remains blocked on a Job Object runner; the -``runner`` injection seam exists so that support does not affect DAG semantics. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Mapping -from dataclasses import dataclass -import json -import os -from pathlib import Path -import re -import subprocess -import uuid - - -_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z") -_OBJECT_ID = re.compile(r"[0-9a-f]{64}\Z") -_RESERVED_ENV = frozenset(("IX_NODE", "IX_OUT", "IX_POOL_CAPACITY")) - - -class GraphError(ValueError): - """The graph or its filesystem boundary is invalid.""" - - -class NodeExecutionError(RuntimeError): - """A node command did not complete successfully.""" - - -@dataclass(frozen=True) -class Command: - argv: tuple[str, ...] - cwd: str = "." - env: tuple[tuple[str, str], ...] = () - - -@dataclass(frozen=True) -class Node: - name: str - object_id: str - deps: tuple[str, ...] = () - pool: str = "cpu" - commands: tuple[Command, ...] = () - - -@dataclass(frozen=True) -class Graph: - nodes: tuple[Node, ...] - targets: tuple[str, ...] - pools: Mapping[str, int] - - -@dataclass(frozen=True) -class ProcessRequest: - node_name: str - pool: str - argv: tuple[str, ...] - cwd: Path - env: Mapping[str, str] - log_path: Path - - -@dataclass(frozen=True) -class NodeResult: - status: str - output_dir: Path - log_path: Path - - -ProcessRunner = Callable[[ProcessRequest], Awaitable[int]] - - -@dataclass -class _VisitState: - lock: asyncio.Lock - result: NodeResult | None = None - - -async def run_process(request: ProcessRequest) -> int: - """Run literal argv without a shell and merge output into the node log. - - Cancellation terminates the direct child and waits for it. A future Windows - Job Object runner can be injected for guaranteed descendant termination. - """ - - request.log_path.parent.mkdir(parents=True, exist_ok=True) - with request.log_path.open("ab", buffering=0) as stream: - process = await asyncio.create_subprocess_exec( - *request.argv, - cwd=str(request.cwd), - env=dict(request.env), - stdout=stream, - stderr=subprocess.STDOUT, - ) - try: - return await process.wait() - except asyncio.CancelledError: - if process.returncode is None: - process.terminate() - await process.wait() - raise - - -class Executor: - """Execute only target ancestors using the core pg83/ix visit algorithm.""" - - def __init__( - self, - graph: Graph, - workspace: Path, - state_root: Path, - *, - runner: ProcessRunner = run_process, - ) -> None: - self.graph = graph - self.workspace = workspace.resolve(strict=True) - self.state_root = state_root.resolve(strict=False) - if self.state_root == self.workspace or not self.state_root.is_relative_to(self.workspace): - raise GraphError("state root must be a strict child of workspace") - - self.runner = runner - self.nodes = self._validate_graph(graph) - self.pool_sizes = dict(graph.pools) - self.pools = {name: asyncio.Semaphore(size) for name, size in self.pool_sizes.items()} - self.visits = { - name: _VisitState(lock=asyncio.Lock()) - for name in self.nodes - } - self.objects_root = self.state_root / "objects" - self.logs_root = self.state_root / "logs" - self.trash_root = self.state_root / "trash" - for path in (self.objects_root, self.logs_root, self.trash_root): - path.mkdir(parents=True, exist_ok=True) - - async def run(self) -> dict[str, NodeResult]: - await self._visit_many(self.graph.targets) - return { - name: state.result - for name, state in self.visits.items() - if state.result is not None - } - - def output_dir(self, node_name: str) -> Path: - node = self.nodes[node_name] - return self.objects_root / node.object_id[:2] / node.object_id - - async def _visit(self, name: str) -> NodeResult: - state = self.visits[name] - async with state.lock: - if state.result is not None: - return state.result - - node = self.nodes[name] - if self._is_complete(node): - state.result = self._result(node, "cached", f"CACHE {name}\n") - return state.result - - await self._visit_many(node.deps) - async with self.pools[node.pool]: - state.result = await self._execute(node) - return state.result - - async def _visit_many(self, names: tuple[str, ...]) -> None: - tasks = [asyncio.create_task(self._visit(name)) for name in names] - try: - await asyncio.gather(*tasks) - except BaseException: - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - raise - - async def _execute(self, node: Node) -> NodeResult: - out_dir = self.output_dir(node.name) - log_path = self._log_path(node) - self._prepare_dir(out_dir) - log_path.write_text(f"ENTER {node.name}\n", encoding="utf-8") - - try: - for command in node.commands: - request = ProcessRequest( - node_name=node.name, - pool=node.pool, - argv=command.argv, - cwd=self._command_cwd(command), - env=self._command_env(node, command, out_dir), - log_path=log_path, - ) - return_code = await self.runner(request) - if return_code != 0: - raise NodeExecutionError( - f"node {node.name!r} failed with exit code {return_code}; " - f"see {log_path}" - ) - - self._publish_marker(node, out_dir) - self._append_log(log_path, f"LEAVE {node.name}\n") - return NodeResult("executed", out_dir, log_path) - except asyncio.CancelledError: - self._append_log(log_path, f"CANCEL {node.name}\n") - self._move_to_trash(out_dir) - raise - except Exception as error: - self._append_log(log_path, f"ERROR {node.name}: {error}\n") - self._move_to_trash(out_dir) - if isinstance(error, NodeExecutionError): - raise - raise NodeExecutionError(f"node {node.name!r} failed; see {log_path}") from error - - def _validate_graph(self, graph: Graph) -> dict[str, Node]: - if not graph.nodes: - raise GraphError("graph must contain nodes") - nodes: dict[str, Node] = {} - object_ids: set[str] = set() - for node in graph.nodes: - if not _NAME.fullmatch(node.name): - raise GraphError(f"invalid node name: {node.name!r}") - if node.name in nodes: - raise GraphError(f"duplicate node: {node.name}") - if not _OBJECT_ID.fullmatch(node.object_id): - raise GraphError(f"node {node.name!r} has invalid object id") - if node.object_id in object_ids: - raise GraphError(f"duplicate object id: {node.object_id}") - self._validate_commands(node) - nodes[node.name] = node - object_ids.add(node.object_id) - - self._validate_references(graph, nodes) - self._validate_cycles(nodes) - return nodes - - def _validate_commands(self, node: Node) -> None: - for command in node.commands: - if not command.argv or any(not isinstance(arg, str) or "\0" in arg for arg in command.argv): - raise GraphError(f"node {node.name!r} has invalid argv") - keys = [key for key, _value in command.env] - if len(keys) != len(set(keys)): - raise GraphError(f"node {node.name!r} has duplicate environment keys") - if _RESERVED_ENV.intersection(keys): - raise GraphError(f"node {node.name!r} overrides reserved environment") - self._command_cwd(command) - - def _validate_references(self, graph: Graph, nodes: Mapping[str, Node]) -> None: - if not graph.targets: - raise GraphError("graph must contain targets") - unknown_targets = sorted(set(graph.targets).difference(nodes)) - if unknown_targets: - raise GraphError(f"unknown targets: {', '.join(unknown_targets)}") - for name, size in graph.pools.items(): - if not _NAME.fullmatch(name) or isinstance(size, bool) or not isinstance(size, int) or size <= 0: - raise GraphError(f"invalid pool: {name!r}") - for node in nodes.values(): - unknown = sorted(set(node.deps).difference(nodes)) - if unknown: - raise GraphError(f"node {node.name!r} has unknown dependencies: {', '.join(unknown)}") - if node.pool not in graph.pools: - raise GraphError(f"node {node.name!r} uses unknown pool {node.pool!r}") - - @staticmethod - def _validate_cycles(nodes: Mapping[str, Node]) -> None: - active: list[str] = [] - complete: set[str] = set() - - def visit(name: str) -> None: - if name in complete: - return - if name in active: - start = active.index(name) - raise GraphError("cycle: " + " -> ".join((*active[start:], name))) - active.append(name) - for dependency in nodes[name].deps: - visit(dependency) - active.pop() - complete.add(name) - - for name in nodes: - visit(name) - - def _command_cwd(self, command: Command) -> Path: - candidate = Path(command.cwd) - if not candidate.is_absolute(): - candidate = self.workspace / candidate - resolved = candidate.resolve(strict=False) - if not resolved.is_relative_to(self.workspace): - raise GraphError("command cwd must stay within workspace") - if not resolved.is_dir(): - raise GraphError(f"command cwd is not a directory: {resolved}") - return resolved - - def _command_env(self, node: Node, command: Command, out_dir: Path) -> dict[str, str]: - env = dict(os.environ) - env.update(command.env) - env.update( - { - "IX_NODE": node.name, - "IX_OUT": str(out_dir), - "IX_POOL_CAPACITY": str(self.pool_sizes[node.pool]), - } - ) - return env - - def _is_complete(self, node: Node) -> bool: - out_dir = self.output_dir(node.name) - if self._is_link(out_dir) or not out_dir.is_dir(): - return False - marker = out_dir / ".complete" - try: - return marker.is_file() and marker.read_text(encoding="utf-8") == self._marker_text(node) - except OSError: - return False - - def _prepare_dir(self, path: Path) -> None: - self._move_to_trash(path) - path.mkdir(parents=True, exist_ok=False) - - def _move_to_trash(self, path: Path) -> None: - if not os.path.lexists(path): - return - destination = self.trash_root / f"{path.name}-{uuid.uuid4().hex}" - os.replace(path, destination) - - @staticmethod - def _is_link(path: Path) -> bool: - is_junction = getattr(path, "is_junction", lambda: False) - return path.is_symlink() or is_junction() - - @staticmethod - def _marker_text(node: Node) -> str: - return json.dumps( - {"object_id": node.object_id, "schema": 1}, - sort_keys=True, - separators=(",", ":"), - ) + "\n" - - def _publish_marker(self, node: Node, out_dir: Path) -> None: - temporary = out_dir / f".complete-{uuid.uuid4().hex}.tmp" - temporary.write_text(self._marker_text(node), encoding="utf-8") - os.replace(temporary, out_dir / ".complete") - - def _log_path(self, node: Node) -> Path: - return self.logs_root / f"{node.name}-{node.object_id[:12]}.log" - - def _result(self, node: Node, status: str, message: str) -> NodeResult: - log_path = self._log_path(node) - log_path.write_text(message, encoding="utf-8") - return NodeResult(status, self.output_dir(node.name), log_path) - - @staticmethod - def _append_log(path: Path, message: str) -> None: - with path.open("a", encoding="utf-8") as stream: - stream.write(message) diff --git a/build/ixdag/graph.py b/build/ixdag/graph.py deleted file mode 100644 index 1a1b546..0000000 --- a/build/ixdag/graph.py +++ /dev/null @@ -1,488 +0,0 @@ -"""A compact, typed, content-addressed Observer micro-DAG. - -The descriptor/signature split follows the useful core of pg83/ix's MIT-licensed -``core/sign.py`` and ``core/gg.py``. Observer paths, Windows argv and validation -are intentionally new and much narrower than IX's package/realm implementation. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from hashlib import sha256 -import json -from pathlib import Path, PurePosixPath -import re - - -_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]*$") -_GLOB = re.compile(r"[*?\[\]]") -Renderer = Callable[[Mapping[str, object]], "Node"] - - -class GraphError(ValueError): - """Raised when a descriptor graph is ambiguous or unsafe.""" - - -def _relative_path(value: str) -> str: - path = PurePosixPath(value.replace("\\", "/")) - if not value or path.is_absolute() or ".." in path.parts: - raise GraphError(f"path must be repository-relative: {value!r}") - if _GLOB.search(value): - raise GraphError(f"exact paths cannot contain glob syntax: {value!r}") - return path.as_posix() - - -def _unique(values: Sequence[str], what: str) -> tuple[str, ...]: - result = tuple(values) - if len(result) != len(set(result)): - raise GraphError(f"duplicate {what}") - return result - - -@dataclass(frozen=True) -class TranslationUnit: - project: str - source: str - slug: str - - def __post_init__(self) -> None: - if not _NAME.fullmatch(self.project) or not _NAME.fullmatch(self.slug): - raise GraphError("translation-unit project and slug must be safe names") - object.__setattr__(self, "source", _relative_path(self.source)) - - -@dataclass(frozen=True) -class Project: - name: str - project_file: str - configurations: tuple[str, ...] - translation_units: tuple[TranslationUnit, ...] - - def __post_init__(self) -> None: - if not _NAME.fullmatch(self.name): - raise GraphError(f"unsafe project name: {self.name!r}") - object.__setattr__(self, "project_file", _relative_path(self.project_file)) - object.__setattr__( - self, "configurations", _unique(self.configurations, "project configuration") - ) - if not self.configurations or not self.translation_units: - raise GraphError(f"project {self.name!r} has an empty matrix axis") - if any(unit.project != self.name for unit in self.translation_units): - raise GraphError(f"translation unit belongs to another project: {self.name}") - _unique(tuple(unit.slug for unit in self.translation_units), "translation-unit slug") - - -@dataclass(frozen=True) -class ObserverMatrix: - shared_inputs: tuple[str, ...] - projects: tuple[Project, ...] - test_configurations: tuple[str, ...] - fuzz_targets: tuple[str, ...] - fuzz_seed_inputs: Mapping[str, tuple[str, ...]] - leak_modes: tuple[str, ...] - leak_scenarios: tuple[str, ...] - - def __post_init__(self) -> None: - object.__setattr__( - self, - "shared_inputs", - _unique(tuple(_relative_path(path) for path in self.shared_inputs), "shared input"), - ) - _unique(tuple(project.name for project in self.projects), "project name") - for axis_name, values in ( - ("test configuration", self.test_configurations), - ("fuzz target", self.fuzz_targets), - ("leak mode", self.leak_modes), - ("leak scenario", self.leak_scenarios), - ): - if not values or any(not _NAME.fullmatch(value.lower()) for value in values): - raise GraphError(f"invalid or empty {axis_name} axis") - _unique(values, axis_name) - if set(self.fuzz_seed_inputs) != set(self.fuzz_targets): - raise GraphError("fuzz seed inputs must exactly cover the fuzz-target axis") - normalized_seeds = { - target: _unique( - tuple(_relative_path(path) for path in self.fuzz_seed_inputs[target]), - f"{target} fuzz seed", - ) - for target in self.fuzz_targets - } - if any(not paths for paths in normalized_seeds.values()): - raise GraphError("every fuzz target needs at least one exact seed input") - object.__setattr__(self, "fuzz_seed_inputs", normalized_seeds) - - -@dataclass(frozen=True) -class Node: - name: str - deps: tuple[str, ...] - pool: str - argv: tuple[str, ...] - inputs: tuple[str, ...] - outputs: tuple[str, ...] - cacheable: bool = True - - def __post_init__(self) -> None: - if not _NAME.fullmatch(self.name): - raise GraphError(f"unsafe node name: {self.name!r}") - if not _NAME.fullmatch(self.pool): - raise GraphError(f"unsafe pool name: {self.pool!r}") - object.__setattr__(self, "deps", tuple(sorted(_unique(self.deps, "dependency")))) - object.__setattr__(self, "argv", tuple(self.argv)) - object.__setattr__( - self, "inputs", tuple(sorted(_unique(tuple(_relative_path(p) for p in self.inputs), "input"))) - ) - object.__setattr__( - self, - "outputs", - tuple(sorted(_unique(tuple(_relative_path(p) for p in self.outputs), "output"))), - ) - if not self.argv or not self.outputs: - raise GraphError(f"node {self.name!r} needs argv and at least one output") - - @classmethod - def from_mapping(cls, value: Mapping[str, object]) -> "Node": - return cls( - name=str(value["name"]), - deps=tuple(str(item) for item in value["deps"]), # type: ignore[union-attr] - pool=str(value["pool"]), - argv=tuple(str(item) for item in value["argv"]), # type: ignore[union-attr] - inputs=tuple(str(item) for item in value["inputs"]), # type: ignore[union-attr] - outputs=tuple(str(item) for item in value["outputs"]), # type: ignore[union-attr] - cacheable=bool(value.get("cacheable", True)), - ) - - def mapping(self) -> dict[str, object]: - return { - "name": self.name, - "deps": list(self.deps), - "pool": self.pool, - "argv": list(self.argv), - "inputs": list(self.inputs), - "outputs": list(self.outputs), - "cacheable": self.cacheable, - } - - -def direct_renderer(descriptor: Mapping[str, object]) -> Node: - """Test/bootstrap renderer; production uses the equivalent Jinja descriptor.""" - - return Node.from_mapping(descriptor) - - -def jinja_renderer(descriptor: Mapping[str, object]) -> Node: - """Render only repetitive JSON emission, keeping topology in typed Python.""" - - try: - from jinja2 import Environment, FileSystemLoader, StrictUndefined - except ImportError as error: - raise GraphError("Jinja2 is required to render ixdag descriptors") from error - template_root = Path(__file__).with_name("templates") - environment = Environment( - loader=FileSystemLoader(template_root), - autoescape=False, - undefined=StrictUndefined, - trim_blocks=True, - lstrip_blocks=True, - ) - rendered = environment.get_template("node.json.j2").render(descriptor=descriptor) - value = json.loads(rendered) - if not isinstance(value, dict): - raise GraphError("node template did not emit a JSON object") - return Node.from_mapping(value) - - -@dataclass(frozen=True) -class Graph: - nodes: tuple[Node, ...] - targets: tuple[str, ...] - - def __post_init__(self) -> None: - ordered = tuple(sorted(self.nodes, key=lambda node: node.name)) - object.__setattr__(self, "nodes", ordered) - names = _unique(tuple(node.name for node in ordered), "node name") - known = set(names) - object.__setattr__(self, "targets", tuple(sorted(_unique(self.targets, "target")))) - if not self.targets or any(target not in known for target in self.targets): - raise GraphError("targets must name nodes in the graph") - owners: dict[str, str] = {} - for node in ordered: - for output in node.outputs: - if output in owners: - raise GraphError(f"duplicate output owner: {output}") - owners[output] = node.name - for node in ordered: - if any(dep not in known or dep == node.name for dep in node.deps): - raise GraphError(f"unknown or self dependency in {node.name}") - for input_path in node.inputs: - owner = owners.get(input_path) - if owner is not None and owner not in node.deps: - raise GraphError( - f"generated input {input_path!r} is not a direct dependency of {node.name}" - ) - self._topological_names() - - def _topological_names(self) -> tuple[str, ...]: - by_name = {node.name: node for node in self.nodes} - visiting: set[str] = set() - visited: set[str] = set() - result: list[str] = [] - - def visit(name: str) -> None: - if name in visiting: - raise GraphError(f"dependency cycle at {name}") - if name in visited: - return - visiting.add(name) - for dependency in by_name[name].deps: - visit(dependency) - visiting.remove(name) - visited.add(name) - result.append(name) - - for node in self.nodes: - visit(node.name) - return tuple(result) - - def descriptors(self, workspace: Path) -> dict[str, dict[str, object]]: - """Seal nodes with source bytes and upstream UIDs, like IX store identities.""" - - root = workspace.resolve() - by_name = {node.name: node for node in self.nodes} - owners = {output: node.name for node in self.nodes for output in node.outputs} - sealed: dict[str, dict[str, object]] = {} - for name in self._topological_names(): - node = by_name[name] - content: list[dict[str, str]] = [] - for input_path in node.inputs: - if owner := owners.get(input_path): - content.append({"path": input_path, "producer": sealed[owner]["uid"]}) # type: ignore[dict-item] - continue - path = root.joinpath(*PurePosixPath(input_path).parts) - if not path.is_file(): - raise GraphError(f"exact source input does not exist: {input_path}") - content.append({"path": input_path, "sha256": sha256(path.read_bytes()).hexdigest()}) - descriptor = node.mapping() - payload = {"schema": "observer-ixdag-v1", "node": descriptor, "content": content} - uid = sha256( - json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest() - sealed[name] = {**descriptor, "uid": uid} - return {name: sealed[name] for name in sorted(sealed)} - - -def _argv(action: str, *arguments: str) -> tuple[str, ...]: - return ("pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1", action, *arguments) - - -def _binary_output(configuration: str, project: str) -> str: - extension = ".exe" if project in {"tests", "leak-probe"} or project.startswith("fuzz-") else ".so" - return f".artifacts/bin/x64/{configuration}/{project}{extension}" - - -def _build_name(configuration: str, project: str) -> str: - if configuration.lower() == "fuzz" and project.startswith("fuzz-"): - return f"build-{project}" - return f"build-{configuration.lower()}-{project}" - - -def build_observer_microdag( - matrix: ObserverMatrix, *, renderer: Renderer = jinja_renderer -) -> Graph: - """Expand all Observer axes into small independently schedulable leaves.""" - - nodes: list[Node] = [] - outputs: dict[str, tuple[str, ...]] = {} - - def add( - name: str, - deps: Sequence[str], - pool: str, - argv: Sequence[str], - sources: Sequence[str], - output: str, - *, - cacheable: bool = True, - ) -> str: - dependencies = tuple(sorted(deps)) - generated = tuple(path for dependency in dependencies for path in outputs[dependency]) - descriptor: dict[str, object] = { - "name": name, - "deps": dependencies, - "pool": pool, - "argv": tuple(argv), - "inputs": tuple(dict.fromkeys((*sources, *generated))), - "outputs": (output,), - "cacheable": cacheable, - } - node = renderer(descriptor) - nodes.append(node) - outputs[name] = node.outputs - return name - - source = add( - "source-checks", - (), - "checks", - _argv("source-checks"), - matrix.shared_inputs, - ".artifacts/reports/source-checks.json", - cacheable=False, - ) - build_names: dict[tuple[str, str], str] = {} - for project in matrix.projects: - project_sources = (project.project_file, *(unit.source for unit in project.translation_units), *matrix.shared_inputs) - for configuration in project.configurations: - name = _build_name(configuration, project.name) - build_names[(configuration.lower(), project.name)] = add( - name, - (source,), - "msbuild", - _argv( - "graph-leaf", - "-GraphLeafAction", - "build-project", - "-Arch", - "x64", - "-Config", - configuration, - "-Project", - project.name, - ), - project_sources, - _binary_output(configuration, project.name), - ) - - test_runs: list[str] = [] - for configuration in matrix.test_configurations: - key = configuration.lower() - dependencies = tuple( - build_names[(key, project.name)] - for project in matrix.projects - if (key, project.name) in build_names - and project.name != "leak-probe" - and not project.name.startswith("fuzz-") - ) - test_runs.append( - add( - f"run-tests-{key}", - dependencies, - "tests", - _argv("test", "-Arch", "x64", "-Config", configuration), - (), - f".artifacts/reports/tests/x64-{key}.xml", - cacheable=False, - ) - ) - - backend_merges: list[str] = [] - for backend, pool in (("msvc", "msvc-analysis"), ("tidy", "clang-tidy")): - normalizers: list[str] = [] - for project in matrix.projects: - for unit in project.translation_units: - stem = f"{backend}-{project.name}-{unit.slug}" - raw = f".artifacts/analysis/{backend}/x64/{project.name}/{unit.slug}.raw" - analyze = add( - f"analyze-{stem}", - (source,), - pool, - _argv("graph-leaf", "-GraphLeafAction", f"analyze-{backend}-unit", "-Project", project.name, "-SelectedFile", unit.source), - (project.project_file, unit.source, *matrix.shared_inputs), - raw, - ) - normalizers.append( - add( - f"normalize-{stem}", - (analyze,), - "sarif", - _argv("graph-leaf", "-GraphLeafAction", f"normalize-{backend}-sarif", "-Input", raw), - (), - f".artifacts/reports/{backend}/x64/units/{project.name}-{unit.slug}.sarif", - ) - ) - backend_merges.append( - add( - f"merge-{backend}-sarif", - normalizers, - "sarif", - _argv("graph-leaf", "-GraphLeafAction", "merge-sarif", "-Backend", backend), - (), - f".artifacts/reports/{backend}/x64/{backend}.sarif", - ) - ) - analysis_gate = add( - "analysis-gate", - backend_merges, - "sarif", - _argv("graph-leaf", "-GraphLeafAction", "analysis-gate"), - (), - ".artifacts/reports/analysis/x64/gate.json", - ) - - fuzz_runs: list[str] = [] - for target in matrix.fuzz_targets: - build = build_names[("fuzz", f"fuzz-{target}")] - replay = add( - f"fuzz-replay-{target}", - (build,), - "fuzz", - _argv("fuzz", "-Target", target, "-Phase", "replay"), - matrix.fuzz_seed_inputs[target], - f".artifacts/reports/fuzz/{target}/replay.json", - cacheable=False, - ) - fuzz_runs.append( - add( - f"fuzz-timed-{target}", - (build, replay), - "fuzz", - _argv("fuzz", "-Target", target, "-Phase", "timed"), - (), - f".artifacts/reports/fuzz/{target}/timed.json", - cacheable=False, - ) - ) - fuzz_gate = add( - "fuzz-gate", - fuzz_runs, - "gate", - _argv("fuzz", "-Phase", "aggregate"), - (), - ".artifacts/reports/fuzz/gate.json", - cacheable=False, - ) - - leak_probe = build_names[("release", "leak-probe")] - leak_cases = [ - add( - f"leak-case-{mode}-{scenario}", - (leak_probe,), - "umdh", - _argv("leaks", "-Mode", mode, "-Scenario", scenario), - (), - f".artifacts/reports/leaks/{mode}/{scenario}.json", - cacheable=False, - ) - for mode in matrix.leak_modes - for scenario in matrix.leak_scenarios - ] - leaks_gate = add( - "leaks-gate", - leak_cases, - "gate", - _argv("leaks", "-Phase", "aggregate"), - (), - ".artifacts/reports/leaks/gate.json", - cacheable=False, - ) - verify = add( - "verify", - (analysis_gate, fuzz_gate, leaks_gate, *test_runs), - "gate", - _argv("verify", "-Phase", "aggregate"), - (), - ".artifacts/reports/verify/gate.json", - cacheable=False, - ) - return Graph(tuple(nodes), (verify,)) diff --git a/build/ixdag/templates/node.json.j2 b/build/ixdag/templates/node.json.j2 deleted file mode 100644 index 7c40b5d..0000000 --- a/build/ixdag/templates/node.json.j2 +++ /dev/null @@ -1 +0,0 @@ -{{ descriptor | tojson }} diff --git a/build/lib/analysis-reporting.ps1 b/build/lib/analysis-reporting.ps1 deleted file mode 100644 index 3f7480f..0000000 --- a/build/lib/analysis-reporting.ps1 +++ /dev/null @@ -1,179 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Set-MSVCAnalysisSarifIdentity { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory)][string] $ReportDirectory, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture - ) - - if (-not (Test-Path -LiteralPath $ReportDirectory -PathType Container)) { - return - } - - $reportFiles = @(Get-ChildItem -LiteralPath $ReportDirectory -File -Filter '*.sarif' | Sort-Object Name) - foreach ($reportFile in $reportFiles) { - $sarif = Get-Content -Raw -LiteralPath $reportFile.FullName | ConvertFrom-Json -AsHashtable - if (-not $sarif.Contains('runs') -or $sarif['runs'] -isnot [System.Collections.IList]) { - throw "MSVC analysis report has no SARIF runs array: $($reportFile.FullName)" - } - - $reportName = [System.IO.Path]::GetFileNameWithoutExtension($reportFile.Name) - $runs = @($sarif['runs']) - for ($runIndex = 0; $runIndex -lt $runs.Count; ++$runIndex) { - $run = $runs[$runIndex] - if ($run -isnot [System.Collections.IDictionary]) { - throw "MSVC analysis report contains a non-object run: $($reportFile.FullName)" - } - - if (-not $run.Contains('automationDetails') -or $run['automationDetails'] -isnot [System.Collections.IDictionary]) { - $run['automationDetails'] = [ordered]@{} - } - $identitySuffix = if ($runs.Count -eq 1) { '' } else { "run-$($runIndex + 1)/" } - $run['automationDetails']['id'] = "msvc-analyze/$Architecture/$reportName/$identitySuffix" - } - - if ($PSCmdlet.ShouldProcess($reportFile.FullName, 'Assign stable MSVC SARIF run identities')) { - $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $reportFile.FullName -Encoding utf8 - } - } -} - -function Export-ClangTidySarif { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $ObjectRoot, - [Parameter(Mandatory)][string] $OutputPath, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture - ) - - $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot).TrimEnd( - [System.IO.Path]::DirectorySeparatorChar, - [System.IO.Path]::AltDirectorySeparatorChar - ) - $repositoryPrefix = $resolvedRepositoryRoot + [System.IO.Path]::DirectorySeparatorChar - $diagnosticPattern = '^(?.+)\((?\d+),(?\d+)\):\s+(?warning|error)\s*:\s+(?.+?)\s*$' - $projectSuffixPattern = '^(?.*)\s+\[[^\]]+\.vcxproj\]$' - $ruleSuffixPattern = '^(?.*)\s+\[(?[^\]]+)\]$' - $deduplicationKeys = [System.Collections.Generic.HashSet[string]]::new( - [System.StringComparer]::Ordinal - ) - $rules = [System.Collections.Generic.Dictionary[string, object]]::new( - [System.StringComparer]::Ordinal - ) - $results = [System.Collections.Generic.List[object]]::new() - - $logFiles = @() - if (Test-Path -LiteralPath $ObjectRoot -PathType Container) { - $logFiles = @( - Get-ChildItem -LiteralPath $ObjectRoot -Recurse -File -Filter '*.ClangTidy.log' | - Sort-Object FullName - ) - } - - foreach ($logFile in $logFiles) { - foreach ($line in Get-Content -LiteralPath $logFile.FullName) { - $diagnosticMatch = [regex]::Match($line, $diagnosticPattern) - if (-not $diagnosticMatch.Success) { - continue - } - - $body = $diagnosticMatch.Groups['body'].Value - $projectMatch = [regex]::Match($body, $projectSuffixPattern) - if ($projectMatch.Success) { - $body = $projectMatch.Groups['diagnostic'].Value - } - $ruleMatch = [regex]::Match($body, $ruleSuffixPattern) - if (-not $ruleMatch.Success) { - continue - } - - $ruleId = @( - $ruleMatch.Groups['checks'].Value -split ',' | - ForEach-Object { $_.Trim() } | - Where-Object { $_ -and -not $_.StartsWith('-', [System.StringComparison]::Ordinal) } - ) | Select-Object -First 1 - if (-not $ruleId) { - continue - } - - try { - $resolvedDiagnosticPath = [System.IO.Path]::GetFullPath($diagnosticMatch.Groups['path'].Value) - } catch { - continue - } - if (-not $resolvedDiagnosticPath.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - continue - } - - $relativePath = [System.IO.Path]::GetRelativePath($resolvedRepositoryRoot, $resolvedDiagnosticPath). - Replace([System.IO.Path]::DirectorySeparatorChar, [char]'/') - $lineNumber = [int] $diagnosticMatch.Groups['line'].Value - $columnNumber = [int] $diagnosticMatch.Groups['column'].Value - $level = $diagnosticMatch.Groups['severity'].Value - $message = $ruleMatch.Groups['message'].Value.Trim() - $deduplicationKey = "$relativePath`n$lineNumber`n$columnNumber`n$level`n$ruleId`n$message" - if (-not $deduplicationKeys.Add($deduplicationKey)) { - continue - } - - if (-not $rules.ContainsKey($ruleId)) { - $rules.Add($ruleId, [ordered]@{ - id = $ruleId - name = $ruleId - shortDescription = [ordered]@{ text = "clang-tidy check $ruleId" } - }) - } - $results.Add([ordered]@{ - ruleId = $ruleId - level = $level - message = [ordered]@{ text = $message } - locations = @( - [ordered]@{ - physicalLocation = [ordered]@{ - artifactLocation = [ordered]@{ uri = $relativePath } - region = [ordered]@{ - startLine = $lineNumber - startColumn = $columnNumber - } - } - } - ) - }) - } - } - - $sortedResults = @( - $results | - Sort-Object ` - @{ Expression = { $_.locations[0].physicalLocation.artifactLocation.uri } }, ` - @{ Expression = { $_.locations[0].physicalLocation.region.startLine } }, ` - @{ Expression = { $_.locations[0].physicalLocation.region.startColumn } }, ` - ruleId, ` - @{ Expression = { $_.message.text } } - ) - $sortedRules = @($rules.Values | Sort-Object id) - $sarif = [ordered]@{ - version = '2.1.0' - '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' - runs = @( - [ordered]@{ - automationDetails = [ordered]@{ id = "clang-tidy/$Architecture/" } - tool = [ordered]@{ - driver = [ordered]@{ - name = 'clang-tidy' - informationUri = 'https://clang.llvm.org/extra/clang-tidy/' - rules = $sortedRules - } - } - results = $sortedResults - } - ) - } - - $outputDirectory = Split-Path $OutputPath -Parent - New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null - $sarif | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 -} diff --git a/build/lib/common.ps1 b/build/lib/common.ps1 deleted file mode 100644 index e368837..0000000 --- a/build/lib/common.ps1 +++ /dev/null @@ -1,106 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Write-Step { - param([Parameter(Mandatory)][string] $Message) - Write-Host "`n==> $Message" -ForegroundColor Cyan -} - -function Invoke-Native { - param( - [Parameter(Mandatory)][string] $FilePath, - [Parameter()][string[]] $Arguments = @(), - [Parameter()][string] $WorkingDirectory = $script:RepositoryRoot - ) - - Push-Location $WorkingDirectory - try { - & $FilePath @Arguments - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')" - } - } finally { - Pop-Location - } -} - -function Invoke-NativeCapture { - param( - [Parameter(Mandatory)][string] $FilePath, - [Parameter()][string[]] $Arguments = @(), - [Parameter()][string] $WorkingDirectory = $script:RepositoryRoot - ) - - Push-Location $WorkingDirectory - try { - $output = @(& $FilePath @Arguments 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')`n$($output -join "`n")" - } - return @($output | ForEach-Object { $_.ToString() }) - } finally { - Pop-Location - } -} - -function Get-RequestedArchitecture { - param([Parameter(Mandatory)][string[]] $Requested) - - $requested = @( - $Requested | - ForEach-Object { $_ -split ',' } | - ForEach-Object { $_.Trim().ToLowerInvariant() } | - Where-Object { $_ } - ) - - if ($requested -contains 'all') { - return $script:KnownArchitectures - } - - foreach ($item in $requested) { - if ($item -notin $script:KnownArchitectures) { - throw "Unknown architecture '$item'. Expected x86, x64, arm64, or all." - } - } - - return @($requested | Select-Object -Unique) -} - -function Get-MSBuildPlatform { - param([Parameter(Mandatory)][string] $Architecture) - - switch ($Architecture) { - 'x86' { return 'Win32' } - 'x64' { return 'x64' } - 'arm64' { return 'ARM64' } - default { throw "Unsupported architecture '$Architecture'." } - } -} - -function Get-VcpkgTriplet { - param( - [Parameter(Mandatory)][string] $Architecture, - [Parameter()][ValidateSet('default', 'asan')][string] $Flavor = 'default' - ) - - $suffix = if ($Flavor -eq 'asan') { '-asan' } else { '' } - return "observer-$Architecture-windows-static$suffix" -} - -function Get-BinaryDirectory { - param( - [Parameter(Mandatory)][string] $Architecture, - [Parameter(Mandatory)][string] $Configuration - ) - return Join-Path $script:ArtifactsRoot "bin\$Architecture\$Configuration" -} - -function Resolve-UserPath { - param([Parameter(Mandatory)][string] $Path) - - if ([System.IO.Path]::IsPathFullyQualified($Path)) { - return [System.IO.Path]::GetFullPath($Path) - } - return [System.IO.Path]::GetFullPath((Join-Path $script:RepositoryRoot $Path)) -} diff --git a/build/lib/dynamic-graph-leaves.ps1 b/build/lib/dynamic-graph-leaves.ps1 deleted file mode 100644 index 8354452..0000000 --- a/build/lib/dynamic-graph-leaves.ps1 +++ /dev/null @@ -1,454 +0,0 @@ -#requires -Version 7.4 - -[CmdletBinding()] -param( - [Parameter()][ValidateSet('', 'fuzz', 'leak', 'audit', 'package')][string] $Leaf = '', - [Parameter()][string] $Phase, - [Parameter()][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture = 'x64', - [Parameter()][ValidateSet('pickle', 'renpy', 'rpgmaker', 'zanzarah')][string] $TargetName = 'pickle', - [Parameter()][ValidateSet('operations', 'lifecycle')][string] $Mode = 'operations', - [Parameter()][ValidateSet( - 'small-success', - 'malformed', - 'cancellation', - 'read-failure', - 'write-failure', - 'large-metadata', - 'sparse-metadata' - )][string] $Scenario = 'small-success', - [Parameter()][ValidateRange(1, 1000000)][int] $Warmup = 8, - [Parameter()][ValidateRange(1, 1000000)][int] $Iterations = 100, - [Parameter()][ValidateRange(3, 10)][int] $Windows = 3, - [Parameter()][ValidateRange(1, 86400)][int] $Seconds = 60, - [Parameter()][string] $Label, - [Parameter()][string] $RunId -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -$null = $Warmup, $Iterations, $Windows, $Label - -$script:DynamicFuzzTargetSpecs = @( - [pscustomobject]@{ Name = 'pickle'; MaxLength = 262144 }, - [pscustomobject]@{ Name = 'renpy'; MaxLength = 1048576 }, - [pscustomobject]@{ Name = 'rpgmaker'; MaxLength = 1048576 }, - [pscustomobject]@{ Name = 'zanzarah'; MaxLength = 1048576 } -) -$script:DynamicLeakScenarioNames = @( - 'small-success', - 'malformed', - 'cancellation', - 'read-failure', - 'write-failure', - 'large-metadata', - 'sparse-metadata' -) -$script:DynamicRepositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - -function Assert-DynamicRunId { - param([Parameter(Mandatory)][string] $RunId) - - if ($RunId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { - throw "Dynamic run ID must be a safe 1-128 character path component: '$RunId'." - } -} - -function Get-DynamicFuzzTargetSpec { - param([Parameter()][string] $TargetName) - - if (-not $TargetName) { - return $script:DynamicFuzzTargetSpecs - } - $result = @($script:DynamicFuzzTargetSpecs | Where-Object Name -CEQ $TargetName) - if ($result.Count -ne 1) { - throw "Unknown dynamic fuzz target: $TargetName" - } - return $result[0] -} - -function Get-DynamicFuzzArgumentList { - param( - [Parameter(Mandatory)][ValidateSet('seed-replay', 'timed')][string] $Phase, - [Parameter(Mandatory)][string] $TargetName, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $InputPath, - [Parameter(Mandatory)][string] $ArtifactDirectory, - [Parameter()] $Seconds - ) - - $spec = Get-DynamicFuzzTargetSpec -TargetName $TargetName - if ($InputPath.Count -eq 0 -or @($InputPath | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -ne 0) { - throw 'Dynamic fuzz input paths must be non-empty.' - } - if ([string]::IsNullOrWhiteSpace($ArtifactDirectory)) { - throw 'Dynamic fuzz artifact directory must be non-empty.' - } - - $artifactPrefix = $ArtifactDirectory.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar - $arguments = [System.Collections.Generic.List[string]]::new() - foreach ($inputPathItem in $InputPath) { - $arguments.Add($inputPathItem) - } - $arguments.Add("-max_len=$($spec.MaxLength)") - $arguments.Add('-rss_limit_mb=1024') - $arguments.Add('-timeout=10') - $arguments.Add('-print_final_stats=1') - $arguments.Add("-artifact_prefix=$artifactPrefix") - if ($Phase -eq 'timed') { - if ($null -eq $Seconds -or $Seconds -is [bool] -or $Seconds -isnot [int] -or $Seconds -lt 1 -or $Seconds -gt 86400) { - throw 'Timed fuzzing requires Seconds from 1 to 86400.' - } - if ($InputPath.Count -ne 1) { - throw 'Timed fuzzing requires exactly one writable corpus directory.' - } - $arguments.Add("-max_total_time=$Seconds") - $arguments.Add('-use_value_profile=1') - } - return ,$arguments.ToArray() -} - -function Initialize-DynamicFuzzCorpus { - param( - [Parameter(Mandatory)][string] $SeedDirectory, - [Parameter(Mandatory)][string] $CorpusDirectory - ) - - $resolvedSeedDirectory = [System.IO.Path]::GetFullPath($SeedDirectory) - $resolvedCorpusDirectory = [System.IO.Path]::GetFullPath($CorpusDirectory) - if (-not (Test-Path -LiteralPath $resolvedSeedDirectory -PathType Container)) { - throw "Fuzzer seed directory was not found: $resolvedSeedDirectory" - } - if ($resolvedCorpusDirectory.Equals($resolvedSeedDirectory, [System.StringComparison]::OrdinalIgnoreCase)) { - throw 'Fuzzer seed and corpus directories must be different.' - } - - $plans = [System.Collections.Generic.List[object]]::new() - $destinations = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($seed in @(Get-ChildItem -LiteralPath $resolvedSeedDirectory -File | Sort-Object Name)) { - if (($seed.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Fuzzer seed must not be a reparse point: $($seed.FullName)" - } - if ($seed.Length -gt 16MB) { - throw "Fuzzer seed exceeds the 16 MiB preparation bound: $($seed.FullName)" - } - - $destinationName = if ($seed.Extension -CEQ '.hex') { $seed.BaseName } else { $seed.Name } - if (-not $destinations.Add($destinationName)) { - throw "Fuzzer seeds map to a duplicate corpus name: $destinationName" - } - $bytes = if ($seed.Extension -CEQ '.hex') { - $hex = (Get-Content -Raw -LiteralPath $seed.FullName) -replace '\s', '' - if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { - throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" - } - [Convert]::FromHexString($hex) - } - else { - [System.IO.File]::ReadAllBytes($seed.FullName) - } - $plans.Add([pscustomobject]@{ Name = $destinationName; Bytes = $bytes }) - } - if ($plans.Count -eq 0) { - throw "No checked-in fuzzer seeds were found in: $resolvedSeedDirectory" - } - - New-Item -ItemType Directory -Force -Path $resolvedCorpusDirectory | Out-Null - foreach ($plan in $plans) { - [System.IO.File]::WriteAllBytes((Join-Path $resolvedCorpusDirectory $plan.Name), $plan.Bytes) - } - return @($plans | ForEach-Object { Join-Path $resolvedCorpusDirectory $_.Name }) -} - -function Write-DynamicLeafJson { - param( - [Parameter(Mandatory)] $Value, - [Parameter(Mandatory)][string] $Path - ) - - $resolvedPath = [System.IO.Path]::GetFullPath($Path) - $parent = [System.IO.Path]::GetDirectoryName($resolvedPath) - if ([string]::IsNullOrEmpty($parent)) { - throw "Dynamic leaf evidence path has no parent: $resolvedPath" - } - New-Item -ItemType Directory -Force -Path $parent | Out-Null - $temporaryPath = "$resolvedPath.$([guid]::NewGuid().ToString('N')).tmp" - try { - $json = $Value | ConvertTo-Json -Depth 8 - [System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false)) - [System.IO.File]::Move($temporaryPath, $resolvedPath, $true) - } - finally { - if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { - Remove-Item -LiteralPath $temporaryPath -Force - } - } -} - -function Invoke-DynamicNativeProcess { - param( - [Parameter(Mandatory)][string] $FilePath, - [Parameter(Mandatory)][string[]] $Arguments, - [Parameter(Mandatory)][string] $WorkingDirectory, - [Parameter(Mandatory)][string] $LogPath - ) - - $startInfo = [System.Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = $FilePath - $startInfo.WorkingDirectory = $WorkingDirectory - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - $startInfo.Environment['ASAN_OPTIONS'] = 'halt_on_error=1:alloc_dealloc_mismatch=1' - foreach ($argument in $Arguments) { - $startInfo.ArgumentList.Add($argument) - } - - $process = [System.Diagnostics.Process]::new() - $process.StartInfo = $startInfo - try { - if (-not $process.Start()) { - throw "Failed to start dynamic native leaf: $FilePath" - } - $standardOutput = $process.StandardOutput.ReadToEndAsync() - $standardError = $process.StandardError.ReadToEndAsync() - $process.WaitForExit() - $combinedOutput = $standardOutput.GetAwaiter().GetResult() + $standardError.GetAwaiter().GetResult() - $logParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($LogPath)) - New-Item -ItemType Directory -Force -Path $logParent | Out-Null - [System.IO.File]::WriteAllText($LogPath, $combinedOutput, [System.Text.UTF8Encoding]::new($false)) - return $process.ExitCode - } - finally { - $process.Dispose() - } -} - -function Invoke-DynamicFuzzLeaf { - param( - [Parameter(Mandatory)][ValidateSet('seed-replay', 'timed')][string] $Phase, - [Parameter(Mandatory)][string] $TargetName, - [Parameter(Mandatory)][string] $FuzzerPath, - [Parameter(Mandatory)][string] $SeedDirectory, - [Parameter(Mandatory)][string] $CorpusDirectory, - [Parameter(Mandatory)][string] $RunDirectory, - [Parameter(Mandatory)][string] $WorkingDirectory, - [Parameter(Mandatory)][ValidateRange(1, 86400)][int] $Seconds, - [Parameter()][scriptblock] $NativeInvoker = { - param($FilePath, $Arguments, $WorkingDirectory, $LogPath) - Invoke-DynamicNativeProcess ` - -FilePath $FilePath ` - -Arguments $Arguments ` - -WorkingDirectory $WorkingDirectory ` - -LogPath $LogPath - } - ) - - $resolvedFuzzer = [System.IO.Path]::GetFullPath($FuzzerPath) - $resolvedWorkingDirectory = [System.IO.Path]::GetFullPath($WorkingDirectory) - $resolvedRunDirectory = [System.IO.Path]::GetFullPath($RunDirectory) - if (-not (Test-Path -LiteralPath $resolvedFuzzer -PathType Leaf)) { - throw "Fuzzer executable was not found: $resolvedFuzzer" - } - if (-not (Test-Path -LiteralPath $resolvedWorkingDirectory -PathType Container)) { - throw "Fuzzer working directory was not found: $resolvedWorkingDirectory" - } - if (Test-Path -LiteralPath $resolvedRunDirectory) { - throw "Dynamic fuzz run directory already exists: $resolvedRunDirectory" - } - New-Item -ItemType Directory -Path $resolvedRunDirectory | Out-Null - $artifactDirectory = Join-Path $resolvedRunDirectory 'artifacts' - New-Item -ItemType Directory -Path $artifactDirectory | Out-Null - - $inputs = @( - if ($Phase -eq 'seed-replay') { - $replayDirectory = Join-Path $resolvedRunDirectory 'inputs' - Initialize-DynamicFuzzCorpus -SeedDirectory $SeedDirectory -CorpusDirectory $replayDirectory | - Sort-Object - } - else { - [void](Initialize-DynamicFuzzCorpus -SeedDirectory $SeedDirectory -CorpusDirectory $CorpusDirectory) - [System.IO.Path]::GetFullPath($CorpusDirectory) - } - ) - $argumentParameters = @{ - Phase = $Phase - TargetName = $TargetName - InputPath = $inputs - ArtifactDirectory = $artifactDirectory - } - if ($Phase -eq 'timed') { - $argumentParameters.Seconds = $Seconds - } - $arguments = Get-DynamicFuzzArgumentList @argumentParameters - $logPath = Join-Path $resolvedRunDirectory 'native.log' - $exitCode = & $NativeInvoker $resolvedFuzzer $arguments $resolvedWorkingDirectory $logPath - if ($exitCode -is [bool] -or $exitCode -isnot [int] -or $exitCode -ne 0) { - throw "Dynamic fuzz leaf failed for $TargetName $Phase with exit code '$exitCode'. Log: $logPath" - } - - $evidence = [ordered]@{ - phase = $Phase - target = $TargetName - inputCount = $inputs.Count - log = $logPath - passed = $true - } - Write-DynamicLeafJson -Value $evidence -Path (Join-Path $resolvedRunDirectory 'result.json') - return [pscustomobject]$evidence -} - -function Get-DynamicLeakScenarioName { - return $script:DynamicLeakScenarioNames -} - -function Assert-DynamicLeakScenario { - param([Parameter(Mandatory)][string] $Scenario) - - if ($Scenario -cnotin $script:DynamicLeakScenarioNames) { - throw "Unknown leak scenario: $Scenario" - } -} - -function Get-DynamicLeakProbeArgumentList { - param( - [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, - [Parameter(Mandatory)][string] $Scenario, - [Parameter(Mandatory)][ValidateRange(1, 1000000)][int] $Warmup, - [Parameter(Mandatory)][ValidateRange(1, 1000000)][int] $Iterations, - [Parameter(Mandatory)][ValidateRange(3, 10)][int] $Windows, - [Parameter()][switch] $Automatic - ) - - Assert-DynamicLeakScenario -Scenario $Scenario - $arguments = [System.Collections.Generic.List[string]]::new() - if ($Automatic) { - $arguments.Add('--automatic') - } - foreach ($argument in @( - '--mode', $Mode, - '--scenario', $Scenario, - '--warmup', [string]$Warmup, - '--iterations', [string]$Iterations, - '--windows', [string]$Windows - )) { - $arguments.Add($argument) - } - return ,$arguments.ToArray() -} - -function Assert-DynamicLeakReadyMarker { - param( - [Parameter(Mandatory)][string] $Line, - [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, - [Parameter(Mandatory)][string] $Scenario - ) - - Assert-DynamicLeakScenario -Scenario $Scenario - $pattern = '^OBSERVER_LEAK_PROBE\|READY\|pid=([1-9][0-9]*)\|mode=' + - [regex]::Escape($Mode) + '\|configuration=Release\|scenarios=' + [regex]::Escape($Scenario) + '$' - if ($Line -cnotmatch $pattern) { - throw "Leak probe READY marker does not match mode '$Mode' and scenario '$Scenario'." - } - return [pscustomobject]@{ - ProcessId = [int]$Matches[1] - Mode = $Mode - Scenario = $Scenario - } -} - -function Invoke-DynamicLeakPreflightLeaf { - param( - [Parameter(Mandatory)][string] $ProbePath, - [Parameter(Mandatory)][string] $BinaryDirectory, - [Parameter(Mandatory)][ValidateSet('operations', 'lifecycle')][string] $Mode, - [Parameter(Mandatory)][string] $Scenario, - [Parameter(Mandatory)][string] $EvidencePath, - [Parameter()][scriptblock] $NativeCapture = { - param($FilePath, $Arguments, $WorkingDirectory) - $null = $WorkingDirectory - $output = & $FilePath @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Leak preflight failed with exit code $LASTEXITCODE." - } - return @($output | ForEach-Object ToString) - } - ) - - $resolvedProbe = [System.IO.Path]::GetFullPath($ProbePath) - $resolvedBinaryDirectory = [System.IO.Path]::GetFullPath($BinaryDirectory) - if (-not (Test-Path -LiteralPath $resolvedProbe -PathType Leaf)) { - throw "Leak probe was not found: $resolvedProbe" - } - if (-not (Test-Path -LiteralPath $resolvedBinaryDirectory -PathType Container)) { - throw "Leak probe binary directory was not found: $resolvedBinaryDirectory" - } - $arguments = Get-DynamicLeakProbeArgumentList ` - -Mode $Mode ` - -Scenario $Scenario ` - -Warmup 1 ` - -Iterations 1 ` - -Windows 3 ` - -Automatic - $output = @(& $NativeCapture $resolvedProbe $arguments $resolvedBinaryDirectory) - $errors = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|ERROR|', [StringComparison]::Ordinal) }) - if ($errors.Count -ne 0) { - throw "Leak preflight reported an error: $($errors -join '; ')" - } - $readyLines = @($output | Where-Object { $_.StartsWith('OBSERVER_LEAK_PROBE|READY|', [StringComparison]::Ordinal) }) - if ($readyLines.Count -ne 1) { - throw "Leak preflight expected one READY marker, received $($readyLines.Count)." - } - $ready = Assert-DynamicLeakReadyMarker -Line $readyLines[0] -Mode $Mode -Scenario $Scenario - $donePattern = '^OBSERVER_LEAK_PROBE\|DONE\|pid=' + $ready.ProcessId + '\|completed_operations=([1-9][0-9]*)$' - $doneLines = @($output | Where-Object { $_ -cmatch $donePattern }) - if ($doneLines.Count -ne 1) { - throw "Leak preflight expected one matching DONE marker, received $($doneLines.Count)." - } - - $evidence = [ordered]@{ - mode = $Mode - scenario = $Scenario - processId = $ready.ProcessId - passed = $true - } - Write-DynamicLeafJson -Value $evidence -Path $EvidencePath - return [pscustomobject]$evidence -} - -if ($Leaf -eq 'fuzz') { - if ($Architecture -ne 'x64') { - throw 'Dynamic libFuzzer leaves are intentionally x64-only.' - } - if ($Phase -notin @('seed-replay', 'timed')) { - throw "Unknown dynamic fuzz phase: '$Phase'." - } - Assert-DynamicRunId -RunId $RunId - $binaryDirectory = Join-Path $script:DynamicRepositoryRoot '.artifacts\bin\x64\Fuzz' - $targetRoot = Join-Path $script:DynamicRepositoryRoot ".artifacts\fuzz\x64\$TargetName" - $runDirectory = Join-Path $script:DynamicRepositoryRoot ".artifacts\fuzz-runs\$RunId\x64\$TargetName\$Phase" - Invoke-DynamicFuzzLeaf ` - -Phase $Phase ` - -TargetName $TargetName ` - -FuzzerPath (Join-Path $binaryDirectory "fuzz-$TargetName.exe") ` - -SeedDirectory (Join-Path $script:DynamicRepositoryRoot "src\fuzz\corpus\$TargetName") ` - -CorpusDirectory (Join-Path $targetRoot 'corpus') ` - -RunDirectory $runDirectory ` - -WorkingDirectory $binaryDirectory ` - -Seconds $Seconds | Out-Null -} -elseif ($Leaf -eq 'leak' -and $Phase -eq 'preflight') { - if ($Architecture -ne 'x64') { - throw 'Dynamic UMDH leak leaves are intentionally x64-only.' - } - $binaryDirectory = Join-Path $script:DynamicRepositoryRoot '.artifacts\bin\x64\Release' - $evidencePath = Join-Path $script:DynamicRepositoryRoot ".artifacts\reports\leaks\x64\preflight\$Mode\$Scenario.json" - Invoke-DynamicLeakPreflightLeaf ` - -ProbePath (Join-Path $binaryDirectory 'leak-probe.exe') ` - -BinaryDirectory $binaryDirectory ` - -Mode $Mode ` - -Scenario $Scenario ` - -EvidencePath $evidencePath | Out-Null -} -elseif ($Leaf) { - throw "Dynamic leaf '$Leaf' phase '$Phase' is not wired to native execution yet." -} diff --git a/build/lib/graph-leaves.ps1 b/build/lib/graph-leaves.ps1 deleted file mode 100644 index 2c22d51..0000000 --- a/build/lib/graph-leaves.ps1 +++ /dev/null @@ -1,476 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -$script:GraphProjectNames = @( - 'renpy', - 'rpgmaker', - 'zanzarah', - 'tests', - 'fuzz-pickle', - 'fuzz-renpy', - 'fuzz-rpgmaker', - 'fuzz-zanzarah', - 'leak-probe' -) - -function Get-GraphMSBuildPlatform { - param([Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture) - - switch ($Architecture) { - 'x86' { return 'Win32' } - 'x64' { return 'x64' } - 'arm64' { return 'ARM64' } - } -} - -function Resolve-GraphProjectPath { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $Project - ) - - if ($Project -notin $script:GraphProjectNames) { - throw "Native graph project '$Project' is not allowlisted." - } - $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) - $projectPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot "build\projects\$Project.vcxproj")) - $expectedProjectRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot 'build\projects')) - if (-not $projectPath.StartsWith($expectedProjectRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Native graph project escaped the project root: $projectPath" - } - if (-not (Test-Path -LiteralPath $projectPath -PathType Leaf)) { - throw "Native graph project was not found: $projectPath" - } - return $projectPath -} - -function Get-GraphProjectTranslationUnit { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $Project - ) - - $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project - [xml] $projectXml = Get-Content -Raw -LiteralPath $projectPath - $namespace = [System.Xml.XmlNamespaceManager]::new($projectXml.NameTable) - $namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') - $prefix = '$(RepositoryRoot)' - return @( - $projectXml.SelectNodes('//msb:ClCompile[@Include]', $namespace) | - ForEach-Object { - $include = $_.Include.ToString() - if (-not $include.StartsWith($prefix, [System.StringComparison]::Ordinal)) { - throw "Native graph ClCompile path must begin with '$prefix': $include" - } - $relativePath = $include.Substring($prefix.Length).Replace('\', '/') - if ( - [System.IO.Path]::IsPathFullyQualified($relativePath) -or - @($relativePath -split '/') -contains '..' -or - -not $relativePath.EndsWith('.cpp', [System.StringComparison]::OrdinalIgnoreCase) - ) { - throw "Unsafe native graph ClCompile path: $relativePath" - } - $relativePath - } - ) -} - -function Get-GraphTranslationUnitSlug { - param([Parameter(Mandatory)][string] $Source) - - $normalizedSource = $Source.Replace('\', '/') - if ( - [System.IO.Path]::IsPathFullyQualified($normalizedSource) -or - @($normalizedSource -split '/') -contains '..' -or - -not $normalizedSource.EndsWith('.cpp', [System.StringComparison]::OrdinalIgnoreCase) - ) { - throw "Unsafe translation-unit path: $Source" - } - $readable = $normalizedSource - if ($readable.StartsWith('src/', [System.StringComparison]::Ordinal)) { - $readable = $readable.Substring(4) - } - $readable = $readable.Substring(0, $readable.Length - 4).ToLowerInvariant() - $readable = [regex]::Replace($readable, '[^a-z0-9]+', '-').Trim('-') - $digestBytes = [System.Security.Cryptography.SHA256]::HashData( - [System.Text.Encoding]::UTF8.GetBytes($normalizedSource) - ) - $digest = [System.Convert]::ToHexString($digestBytes).ToLowerInvariant().Substring(0, 8) - return "$readable-$digest" -} - -function Assert-GraphProjectUnit { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $Project, - [Parameter(Mandatory)][string] $Unit - ) - - $expectedUnits = @( - Get-GraphProjectTranslationUnit -RepositoryRoot $RepositoryRoot -Project $Project | - ForEach-Object { Get-GraphTranslationUnitSlug -Source $_ } - ) - if ($Unit -cnotin $expectedUnits) { - throw "Translation-unit identity '$Unit' is not a ClCompile unit in $Project.vcxproj." - } -} - -function Assert-GraphProjectConfiguration { - param( - [Parameter(Mandatory)][string] $Project, - [Parameter(Mandatory)][string] $Configuration - ) - - if ($Project -eq 'leak-probe' -and $Configuration -ne 'Release') { - throw 'The leak-probe graph leaf supports only Release|x64.' - } - if ($Project.StartsWith('fuzz-', [System.StringComparison]::Ordinal) -and $Configuration -ne 'Fuzz') { - throw "Fuzz graph project '$Project' requires the Fuzz configuration." - } - if ( - -not $Project.StartsWith('fuzz-', [System.StringComparison]::Ordinal) -and - $Project -ne 'leak-probe' -and - $Configuration -eq 'Fuzz' - ) { - throw "Non-fuzz graph project '$Project' cannot use the Fuzz configuration." - } -} - -function Get-GraphBuildProjectRequest { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, - [Parameter(Mandatory)][ValidateSet('Debug', 'Release', 'Coverage', 'ASan', 'UBSan', 'Fuzz')][string] $Configuration, - [Parameter(Mandatory)][string] $Project - ) - - $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project - Assert-GraphProjectConfiguration -Project $Project -Configuration $Configuration - if (($Configuration -in @('ASan', 'Fuzz')) -and $Architecture -eq 'arm64') { - throw "$Configuration is not supported for ARM64." - } - if (($Configuration -eq 'UBSan' -or $Project -eq 'leak-probe') -and $Architecture -ne 'x64') { - throw "$Configuration/$Project is supported only for x64." - } - - return [pscustomobject]@{ - ProjectPath = $projectPath - Target = 'Build' - Architecture = $Architecture - Platform = Get-GraphMSBuildPlatform -Architecture $Architecture - Configuration = $Configuration - Properties = [ordered]@{} - } -} - -function Get-GraphAnalysisRequest { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, - [Parameter(Mandatory)][ValidateSet('msvc', 'clang-tidy')][string] $Backend, - [Parameter(Mandatory)][string] $Project, - [Parameter()][string] $SelectedFile, - [Parameter()][string] $Unit - ) - - $projectPath = Resolve-GraphProjectPath -RepositoryRoot $RepositoryRoot -Project $Project - $configuration = if ($Project -eq 'leak-probe') { 'Release' } else { 'Debug' } - if ($Project -eq 'leak-probe' -and $Architecture -ne 'x64') { - throw 'leak-probe analysis is supported only for x64.' - } - $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) - $properties = [ordered]@{ - ObserverRunCodeAnalysis = 'true' - RunCodeAnalysis = 'true' - ObserverCompileAnalysis = 'true' - ForceRebuild = 'true' - } - - if (-not $SelectedFile -or -not $Unit) { - throw "$Backend selected-file analysis requires SelectedFile and Unit." - } - $normalizedSource = $SelectedFile.Replace('\', '/') - $translationUnits = @(Get-GraphProjectTranslationUnit -RepositoryRoot $resolvedRepositoryRoot -Project $Project) - if ($normalizedSource -notin $translationUnits) { - throw "'$SelectedFile' is not a ClCompile Include item in $Project.vcxproj." - } - $expectedUnit = Get-GraphTranslationUnitSlug -Source $normalizedSource - if ($Unit -cne $expectedUnit) { - throw "Translation-unit key '$Unit' does not match '$expectedUnit'." - } - $selectedPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot $normalizedSource.Replace('/', '\'))) - $properties.SelectedFiles = $selectedPath - $properties.SelectedFilesBuildPCH = 'false' - $properties.SelectedFilesBuildModules = 'false' - - if ($Backend -eq 'msvc') { - $scratchRoot = Join-Path $resolvedRepositoryRoot ".artifacts\analysis\msvc\$Architecture\$Project\$Unit" - $properties.EnableMicrosoftCodeAnalysis = 'true' - $properties.ObserverEnableClangTidy = 'false' - $properties.IntDir = Join-Path $scratchRoot 'obj\' - $properties.ObserverAnalysisReportName = $Project - $properties.ObserverAnalysisReportPath = Join-Path $scratchRoot "$Project.sarif" - } else { - $scratchRoot = Join-Path $resolvedRepositoryRoot ".artifacts\analysis\clang-tidy\$Architecture\$Project\$Unit" - $properties.EnableMicrosoftCodeAnalysis = 'false' - $properties.ObserverEnableClangTidy = 'true' - $properties.IntDir = Join-Path $scratchRoot 'obj\' - $properties.ClangTidyLogFile = "$Project.ClangTidy.log" - } - - return [pscustomobject]@{ - ProjectPath = $projectPath - Target = 'ClCompile' - Architecture = $Architecture - Platform = Get-GraphMSBuildPlatform -Architecture $Architecture - Configuration = $configuration - Properties = $properties - } -} - -function Resolve-GraphArtifactPath { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $Path - ) - - $resolvedRepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) - $resolvedPath = if ([System.IO.Path]::IsPathFullyQualified($Path)) { - [System.IO.Path]::GetFullPath($Path) - } else { - [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot $Path)) - } - $artifactRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepositoryRoot '.artifacts')) - if (-not $resolvedPath.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Graph artifact path must stay below '$artifactRoot': $resolvedPath" - } - - $current = $resolvedPath - while ($current.StartsWith($artifactRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - if (Test-Path -LiteralPath $current) { - $attributes = [System.IO.File]::GetAttributes($current) - if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Graph artifact path crosses a reparse point: $current" - } - } - if ($current.Equals($artifactRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - break - } - $current = Split-Path $current -Parent - } - return $resolvedPath -} - -function Assert-GraphArtifactPath { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $Path, - [Parameter(Mandatory)][string] $ExpectedRelativePath, - [Parameter(Mandatory)][ValidateSet('input', 'output', 'object root')][string] $Role - ) - - $resolvedPath = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $Path - $expectedPath = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $ExpectedRelativePath - if (-not $resolvedPath.Equals($expectedPath, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Graph SARIF expected $Role path '$expectedPath', received '$resolvedPath'." - } - return $resolvedPath -} - -function Read-GraphSarifDocument { - param( - [Parameter(Mandatory)][string] $Path, - [Parameter(Mandatory)][string] $Description - ) - - $sarif = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable - if ( - $sarif -isnot [System.Collections.IDictionary] -or - -not $sarif.Contains('version') -or - $sarif['version'] -isnot [string] -or - $sarif['version'] -cne '2.1.0' - ) { - throw "$Description must use SARIF version exactly 2.1.0: $Path" - } - if ( - -not $sarif.Contains('runs') -or - $sarif['runs'] -isnot [System.Collections.IList] -or - $sarif['runs'].Count -eq 0 -or - @($sarif['runs'] | Where-Object { $_ -isnot [System.Collections.IDictionary] }).Count -ne 0 - ) { - throw "$Description runs must be a non-empty list of objects: $Path" - } - return $sarif -} - -function Convert-GraphMsvcSarif { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $InputPath, - [Parameter(Mandatory)][string] $OutputPath, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, - [Parameter(Mandatory)][string] $Project, - [Parameter(Mandatory)][string] $Unit - ) - - if ($Project -notin $script:GraphProjectNames) { - throw "Native graph project '$Project' is not allowlisted." - } - Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $Project -Unit $Unit - $resolvedInput = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $InputPath ` - -ExpectedRelativePath ".artifacts\analysis\msvc\$Architecture\$Project\$Unit\$Project.sarif" ` - -Role input - $resolvedOutput = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $OutputPath ` - -ExpectedRelativePath ".artifacts\reports\msvc\$Architecture\units\$Project-$Unit.sarif" ` - -Role output - if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) { - throw "MSVC unit SARIF was not found: $resolvedInput" - } - $sarif = Read-GraphSarifDocument -Path $resolvedInput -Description 'MSVC unit SARIF' - $runs = @($sarif['runs']) - for ($index = 0; $index -lt $runs.Count; ++$index) { - $run = $runs[$index] - if ( - -not $run.ContainsKey('automationDetails') -or - $run['automationDetails'] -isnot [System.Collections.IDictionary] - ) { - $run['automationDetails'] = [ordered]@{} - } - $suffix = if ($runs.Count -eq 1) { '' } else { "run-$($index + 1)/" } - $run['automationDetails']['id'] = "msvc-analyze/$Architecture/$Project/$Unit/$suffix" - } - New-Item -ItemType Directory -Force -Path (Split-Path $resolvedOutput -Parent) | Out-Null - $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 -} - -function Convert-GraphClangTidyUnitSarif { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $ObjectRoot, - [Parameter(Mandatory)][string] $OutputPath, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, - [Parameter(Mandatory)][string] $Project, - [Parameter(Mandatory)][string] $Unit - ) - - if ($Project -notin $script:GraphProjectNames) { - throw "Native graph project '$Project' is not allowlisted." - } - Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $Project -Unit $Unit - $resolvedObjectRoot = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $ObjectRoot ` - -ExpectedRelativePath ".artifacts\analysis\clang-tidy\$Architecture\$Project\$Unit\obj" ` - -Role 'object root' - $resolvedOutput = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $OutputPath ` - -ExpectedRelativePath ".artifacts\reports\clang-tidy\$Architecture\units\$Project-$Unit.sarif" ` - -Role output - Export-ClangTidySarif ` - -RepositoryRoot $RepositoryRoot ` - -ObjectRoot $resolvedObjectRoot ` - -OutputPath $resolvedOutput ` - -Architecture $Architecture - - $sarif = Read-GraphSarifDocument -Path $resolvedOutput -Description 'Clang-tidy unit SARIF' - $runs = @($sarif['runs']) - for ($index = 0; $index -lt $runs.Count; ++$index) { - $run = $runs[$index] - if ( - -not $run.ContainsKey('automationDetails') -or - $run['automationDetails'] -isnot [System.Collections.IDictionary] - ) { - $run['automationDetails'] = [ordered]@{} - } - $suffix = if ($runs.Count -eq 1) { '' } else { "run-$($index + 1)/" } - $run['automationDetails']['id'] = "clang-tidy/$Architecture/$Project/$Unit/$suffix" - } - $sarif | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 -} - -function Merge-GraphSarif { - param( - [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $Architecture, - [Parameter(Mandatory)][ValidateSet('msvc', 'clang-tidy')][string] $Backend, - [Parameter(Mandatory)][string[]] $InputPaths, - [Parameter(Mandatory)][string] $OutputPath - ) - - if ($InputPaths.Count -eq 0) { - throw 'SARIF merge requires at least one input.' - } - $outputName = if ($Backend -eq 'msvc') { 'msvc-analyze.sarif' } else { 'clang-tidy.sarif' } - $resolvedOutput = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $OutputPath ` - -ExpectedRelativePath ".artifacts\reports\$Backend\$Architecture\$outputName" ` - -Role output - $expectedInputRoot = Resolve-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path ".artifacts\reports\$Backend\$Architecture\units" - $runs = [System.Collections.Generic.List[object]]::new() - foreach ($inputPath in $InputPaths) { - $resolvedInput = Resolve-GraphArtifactPath -RepositoryRoot $RepositoryRoot -Path $inputPath - $resolvedInputParent = Split-Path $resolvedInput -Parent - if (-not $resolvedInputParent.Equals($expectedInputRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Graph SARIF expected input path below '$expectedInputRoot', received '$resolvedInput'." - } - if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) { - throw "SARIF merge input was not found: $resolvedInput" - } - $inputName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedInput) - $project = @( - $script:GraphProjectNames | - Sort-Object Length -Descending | - Where-Object { $inputName.StartsWith("$_-", [System.StringComparison]::Ordinal) } - ) | Select-Object -First 1 - if (-not $project) { - throw "SARIF merge input does not identify an allowlisted project: $resolvedInput" - } - $unit = $inputName.Substring($project.Length + 1) - Assert-GraphProjectUnit -RepositoryRoot $RepositoryRoot -Project $project -Unit $unit - $expectedInput = Assert-GraphArtifactPath ` - -RepositoryRoot $RepositoryRoot ` - -Path $resolvedInput ` - -ExpectedRelativePath ".artifacts\reports\$Backend\$Architecture\units\$project-$unit.sarif" ` - -Role input - - $sarif = Read-GraphSarifDocument -Path $expectedInput -Description 'SARIF merge input' - $inputRuns = @($sarif['runs']) - $identityPrefix = if ($Backend -eq 'msvc') { 'msvc-analyze' } else { 'clang-tidy' } - for ($index = 0; $index -lt $inputRuns.Count; ++$index) { - $run = $inputRuns[$index] - $suffix = if ($inputRuns.Count -eq 1) { '' } else { "run-$($index + 1)/" } - $expectedIdentity = "$identityPrefix/$Architecture/$project/$unit/$suffix" - $automationDetails = $run['automationDetails'] - if ( - $automationDetails -isnot [System.Collections.IDictionary] -or - -not $automationDetails.Contains('id') -or - $automationDetails['id'] -isnot [string] -or - $automationDetails['id'] -cne $expectedIdentity - ) { - throw "SARIF merge input does not contain the exact expected unit identities: $resolvedInput" - } - $runs.Add($run) - } - } - $sortedRuns = @($runs | Sort-Object { $_['automationDetails']['id'] }) - $identities = @($sortedRuns | ForEach-Object { $_['automationDetails']['id'] }) - if (@($identities | Sort-Object -Unique).Count -ne $identities.Count) { - throw 'SARIF merge found duplicate automationDetails.id values.' - } - $merged = [ordered]@{ - version = '2.1.0' - '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' - runs = $sortedRuns - } - New-Item -ItemType Directory -Force -Path (Split-Path $resolvedOutput -Parent) | Out-Null - $merged | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 -} diff --git a/build/lib/package-manifest.ps1 b/build/lib/package-manifest.ps1 deleted file mode 100644 index 3dbfa31..0000000 --- a/build/lib/package-manifest.ps1 +++ /dev/null @@ -1,213 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -$script:ObserverModulePackageEntries = @{ - renpy = @( - 'renpy.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/rpatool.txt' - 'docs/thirdparty/serde-pickle.txt' - 'docs/thirdparty/zlib.txt' - ) - rpgmaker = @( - 'rpgmaker.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/rgssad.txt' - ) - zanzarah = @( - 'zanzarah.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/zanzapak.txt' - ) -} -$script:ObserverSymbolsPackageEntries = @('renpy.pdb', 'rpgmaker.pdb', 'zanzarah.pdb') - -function Assert-SafeObserverZipEntryName { - param([Parameter(Mandatory)][AllowEmptyString()][string] $Name) - - $isUnsafe = [string]::IsNullOrEmpty($Name) -or - $Name.Length -gt 4096 -or - $Name[0] -eq '/' -or - $Name[0] -eq '\' -or - $Name.Contains('\', [System.StringComparison]::Ordinal) -or - $Name -match '^[A-Za-z]:' -or - -not $Name.IsNormalized([System.Text.NormalizationForm]::FormC) - if ($isUnsafe) { - throw "Package contains an unsafe ZIP entry name: '$Name'." - } - - foreach ($character in $Name.ToCharArray()) { - if ([char]::IsControl($character)) { - throw "Package contains an unsafe ZIP entry name: '$Name'." - } - } - - $canonicalName = $Name - if ($canonicalName.EndsWith('/', [System.StringComparison]::Ordinal)) { - $canonicalName = $canonicalName.Substring(0, $canonicalName.Length - 1) - } - if ([string]::IsNullOrEmpty($canonicalName)) { - throw "Package contains an unsafe ZIP entry name: '$Name'." - } - - $segments = $canonicalName.Split([char[]]@('/'), [System.StringSplitOptions]::None) - foreach ($segment in $segments) { - $hasUnsafeCharacter = $segment.IndexOfAny([char[]]@('<', '>', ':', '"', '|', '?', '*')) -ge 0 - $baseName = $segment.Split('.', 2)[0] - $isUnsafeSegment = [string]::IsNullOrEmpty($segment) -or - $segment -eq '.' -or - $segment -eq '..' -or - $segment.Length -gt 255 -or - $segment.EndsWith('.', [System.StringComparison]::Ordinal) -or - $segment.EndsWith(' ', [System.StringComparison]::Ordinal) -or - $hasUnsafeCharacter -or - $baseName -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$' - if ($isUnsafeSegment) { - throw "Package contains an unsafe ZIP entry name: '$Name'." - } - } -} - -function Get-ObserverZipManifest { - param([Parameter(Mandatory)][string] $ArchivePath) - - $resolvedArchivePath = [System.IO.Path]::GetFullPath($ArchivePath) - if (-not (Test-Path -LiteralPath $resolvedArchivePath -PathType Leaf)) { - throw "Package archive does not exist: $resolvedArchivePath" - } - - $fileNames = [System.Collections.Generic.List[string]]::new() - $directoryNames = [System.Collections.Generic.List[string]]::new() - $entryNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $fileStream = [System.IO.File]::Open( - $resolvedArchivePath, - [System.IO.FileMode]::Open, - [System.IO.FileAccess]::Read, - [System.IO.FileShare]::Read - ) - try { - $archive = [System.IO.Compression.ZipArchive]::new( - $fileStream, - [System.IO.Compression.ZipArchiveMode]::Read, - $true - ) - try { - foreach ($entry in $archive.Entries) { - $entryName = $entry.FullName - Assert-SafeObserverZipEntryName -Name $entryName - - $isDirectory = $entryName.EndsWith('/', [System.StringComparison]::Ordinal) - $collisionKey = if ($isDirectory) { - $entryName.Substring(0, $entryName.Length - 1) - } - else { - $entryName - } - if (-not $entryNames.Add($collisionKey)) { - throw "Package contains a duplicate or case-colliding ZIP entry: '$entryName'." - } - - if ($isDirectory) { - if ($entry.Length -ne 0) { - throw "Package contains an unsafe ZIP entry with directory data: '$entryName'." - } - $directoryNames.Add($collisionKey) - } - else { - $fileNames.Add($entryName) - } - } - } - finally { - $archive.Dispose() - } - } - finally { - $fileStream.Dispose() - } - - return [pscustomobject]@{ - ArchivePath = $resolvedArchivePath - Files = $fileNames.ToArray() - Directories = $directoryNames.ToArray() - } -} - -function Get-ObserverExpectedDirectoryName { - param([Parameter(Mandatory)][string[]] $FileName) - - $directories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - foreach ($name in $FileName) { - $separatorIndex = $name.LastIndexOf('/', [System.StringComparison]::Ordinal) - while ($separatorIndex -gt 0) { - $null = $directories.Add($name.Substring(0, $separatorIndex)) - $separatorIndex = $name.LastIndexOf('/', $separatorIndex - 1) - } - } - return ,$directories -} - -function Assert-ObserverPackageEntrySet { - param( - [Parameter(Mandatory)][string] $ArchivePath, - [Parameter(Mandatory)][string[]] $ExpectedFileName, - [Parameter(Mandatory)][string] $PackageDescription - ) - - $manifest = Get-ObserverZipManifest -ArchivePath $ArchivePath - $expectedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - foreach ($name in $ExpectedFileName) { - $null = $expectedFiles.Add($name) - } - $actualFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - foreach ($name in @($manifest.Files)) { - $null = $actualFiles.Add($name) - } - $expectedDirectories = Get-ObserverExpectedDirectoryName -FileName $ExpectedFileName - - $missingFiles = @($ExpectedFileName | Where-Object { -not $actualFiles.Contains($_) } | Sort-Object) - $extraFiles = @($manifest.Files | Where-Object { -not $expectedFiles.Contains($_) } | Sort-Object) - $extraDirectories = @( - $manifest.Directories | - Where-Object { -not $expectedDirectories.Contains($_) } | - Sort-Object - ) - if ($missingFiles.Count -ne 0 -or $extraFiles.Count -ne 0 -or $extraDirectories.Count -ne 0) { - $details = @( - if ($missingFiles.Count -ne 0) { "missing: $($missingFiles -join ', ')" } - if ($extraFiles.Count -ne 0) { "extra files: $($extraFiles -join ', ')" } - if ($extraDirectories.Count -ne 0) { "extra directories: $($extraDirectories -join ', ')" } - ) -join '; ' - throw "$PackageDescription manifest does not match the exact allowlist ($details)." - } - - return $manifest -} - -function Assert-ObserverModulePackageManifest { - param( - [Parameter(Mandatory)][string] $ArchivePath, - [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName - ) - - return Assert-ObserverPackageEntrySet ` - -ArchivePath $ArchivePath ` - -ExpectedFileName $script:ObserverModulePackageEntries[$ModuleName] ` - -PackageDescription "$ModuleName module package" -} - -function Assert-ObserverSymbolsPackageManifest { - param([Parameter(Mandatory)][string] $ArchivePath) - - return Assert-ObserverPackageEntrySet ` - -ArchivePath $ArchivePath ` - -ExpectedFileName $script:ObserverSymbolsPackageEntries ` - -PackageDescription 'Combined symbols package' -} diff --git a/build/lib/package-smoke.ps1 b/build/lib/package-smoke.ps1 deleted file mode 100644 index 8d7bdaa..0000000 --- a/build/lib/package-smoke.ps1 +++ /dev/null @@ -1,177 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Get-ObserverExtractedPackageManifest { - param([Parameter(Mandatory)][string] $DirectoryPath) - - $root = [System.IO.Path]::GetFullPath($DirectoryPath) - if (-not (Test-Path -LiteralPath $root -PathType Container)) { - throw "Extracted package directory does not exist: $root" - } - - $files = [System.Collections.Generic.List[string]]::new() - $directories = [System.Collections.Generic.List[string]]::new() - foreach ($item in Get-ChildItem -LiteralPath $root -Force -Recurse) { - if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Extracted package contains a reparse point: $($item.FullName)" - } - - $relativeName = [System.IO.Path]::GetRelativePath($root, $item.FullName).Replace('\', '/') - Assert-SafeObserverZipEntryName -Name $relativeName - if ($item.PSIsContainer) { - $directories.Add($relativeName) - } - else { - $files.Add($relativeName) - } - } - - return [pscustomobject]@{ - DirectoryPath = $root - Files = $files.ToArray() - Directories = $directories.ToArray() - } -} - -function Assert-ObserverExtractedPackageEntrySet { - param( - [Parameter(Mandatory)][string] $DirectoryPath, - [Parameter(Mandatory)][string[]] $ExpectedFileName, - [Parameter(Mandatory)][string] $PackageDescription - ) - - $manifest = Get-ObserverExtractedPackageManifest -DirectoryPath $DirectoryPath - $expectedDirectories = Get-ObserverExpectedDirectoryName -FileName $ExpectedFileName - $actualFiles = [System.Collections.Generic.HashSet[string]]::new( - [string[]]$manifest.Files, - [System.StringComparer]::Ordinal - ) - $actualDirectories = [System.Collections.Generic.HashSet[string]]::new( - [string[]]$manifest.Directories, - [System.StringComparer]::Ordinal - ) - - $missingFiles = @($ExpectedFileName | Where-Object { -not $actualFiles.Contains($_) } | Sort-Object) - $extraFiles = @($manifest.Files | Where-Object { $_ -notin $ExpectedFileName } | Sort-Object) - $missingDirectories = @($expectedDirectories | Where-Object { -not $actualDirectories.Contains($_) } | Sort-Object) - $extraDirectories = @($manifest.Directories | Where-Object { -not $expectedDirectories.Contains($_) } | Sort-Object) - if ( - $missingFiles.Count -ne 0 -or - $extraFiles.Count -ne 0 -or - $missingDirectories.Count -ne 0 -or - $extraDirectories.Count -ne 0 - ) { - $details = @( - if ($missingFiles.Count -ne 0) { "missing files: $($missingFiles -join ', ')" } - if ($extraFiles.Count -ne 0) { "extra files: $($extraFiles -join ', ')" } - if ($missingDirectories.Count -ne 0) { "missing directories: $($missingDirectories -join ', ')" } - if ($extraDirectories.Count -ne 0) { "extra directories: $($extraDirectories -join ', ')" } - ) -join '; ' - throw "$PackageDescription extracted manifest does not match the exact allowlist ($details)." - } - - return $manifest -} - -function Assert-ObserverExtractedModulePackage { - param( - [Parameter(Mandatory)][string] $DirectoryPath, - [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName - ) - - return Assert-ObserverExtractedPackageEntrySet ` - -DirectoryPath $DirectoryPath ` - -ExpectedFileName $script:ObserverModulePackageEntries[$ModuleName] ` - -PackageDescription "$ModuleName module package" -} - -function Assert-ObserverExtractedSymbolsPackage { - param([Parameter(Mandatory)][string] $DirectoryPath) - - return Assert-ObserverExtractedPackageEntrySet ` - -DirectoryPath $DirectoryPath ` - -ExpectedFileName $script:ObserverSymbolsPackageEntries ` - -PackageDescription 'Combined symbols package' -} - -function Expand-ObserverModulePackageForSmoke { - param( - [Parameter(Mandatory)][string] $ArchivePath, - [Parameter(Mandatory)][string] $DestinationPath, - [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName - ) - - $destination = [System.IO.Path]::GetFullPath($DestinationPath) - if (Test-Path -LiteralPath $destination) { - throw "Package smoke destination already exists: $destination" - } - - [void](Assert-ObserverModulePackageManifest -ArchivePath $ArchivePath -ModuleName $ModuleName) - Expand-Archive -LiteralPath $ArchivePath -DestinationPath $destination - [void](Assert-ObserverExtractedModulePackage -DirectoryPath $destination -ModuleName $ModuleName) - return $destination -} - -function Expand-ObserverSymbolsPackageForSmoke { - param( - [Parameter(Mandatory)][string] $ArchivePath, - [Parameter(Mandatory)][string] $DestinationPath - ) - - $destination = [System.IO.Path]::GetFullPath($DestinationPath) - if (Test-Path -LiteralPath $destination) { - throw "Package smoke destination already exists: $destination" - } - - [void](Assert-ObserverSymbolsPackageManifest -ArchivePath $ArchivePath) - Expand-Archive -LiteralPath $ArchivePath -DestinationPath $destination - [void](Assert-ObserverExtractedSymbolsPackage -DirectoryPath $destination) - return $destination -} - -function Invoke-ObserverPackageRuntimeSmoke { - param( - [Parameter(Mandatory)][string] $TestExecutablePath, - [Parameter(Mandatory)][string] $ModulePath, - [Parameter(Mandatory)][ValidateSet('renpy', 'rpgmaker', 'zanzarah')][string] $ModuleName, - [Parameter(Mandatory)][string] $WorkingDirectory, - [Parameter()][string] $ReportPath - ) - - if (-not [System.IO.Path]::IsPathFullyQualified($ModulePath)) { - throw "Package module path must be absolute: $ModulePath" - } - $resolvedModulePath = [System.IO.Path]::GetFullPath($ModulePath) - if (-not (Test-Path -LiteralPath $resolvedModulePath -PathType Leaf)) { - throw "Extracted package module does not exist: $resolvedModulePath" - } - $resolvedTestExecutable = [System.IO.Path]::GetFullPath($TestExecutablePath) - if (-not (Test-Path -LiteralPath $resolvedTestExecutable -PathType Leaf)) { - throw "Package smoke test executable does not exist: $resolvedTestExecutable" - } - - $arguments = [System.Collections.Generic.List[string]]::new() - foreach ($argument in @('[package-smoke]', '--reporter', 'compact', '--rng-seed', '1')) { - $arguments.Add($argument) - } - if ($ReportPath) { - $arguments.Add('--reporter') - $arguments.Add("JUnit::out=$([System.IO.Path]::GetFullPath($ReportPath))") - } - - $previousModule = [Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', 'Process') - $previousFormat = [Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', 'Process') - try { - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $resolvedModulePath, 'Process') - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $ModuleName, 'Process') - Invoke-Native ` - -FilePath $resolvedTestExecutable ` - -Arguments $arguments.ToArray() ` - -WorkingDirectory $WorkingDirectory - } - finally { - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $previousModule, 'Process') - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $previousFormat, 'Process') - } -} diff --git a/build/lib/packaging.ps1 b/build/lib/packaging.ps1 deleted file mode 100644 index a34aefe..0000000 --- a/build/lib/packaging.ps1 +++ /dev/null @@ -1,118 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Invoke-Package { - param([Parameter(Mandatory)][string[]] $Architectures) - - Invoke-Restore -Architectures $Architectures - Invoke-Build -Architectures $Architectures -Configuration 'Release' - Invoke-Audit -Architectures $Architectures - - $date = Get-Date -Format 'yyyy-MM-dd' - $packagesDirectory = Join-Path $script:ArtifactsRoot 'packages' - $moduleLicenseFiles = @{ - renpy = @('Observer.txt', 'rpatool.txt', 'serde-pickle.txt', 'zlib.txt') - rpgmaker = @('Observer.txt', 'rgssad.txt') - zanzarah = @('Observer.txt', 'zanzapak.txt') - } - $packageSmokeRunRoot = Join-Path $script:ArtifactsRoot "package-smoke\$([guid]::NewGuid().ToString('N'))" - $packageSmokeReportDirectory = Join-Path $script:ArtifactsRoot 'reports\package-smoke' - $packageEvidence = [System.Collections.Generic.List[object]]::new() - New-Item -ItemType Directory -Force -Path $packageSmokeReportDirectory | Out-Null - if (Test-Path -LiteralPath $packagesDirectory) { - Remove-Item -Recurse -Force -LiteralPath $packagesDirectory - } - New-Item -ItemType Directory -Force -Path $packagesDirectory | Out-Null - - foreach ($architecture in $Architectures) { - $binaryDirectory = Get-BinaryDirectory -Architecture $architecture -Configuration 'Release' - $architectureSymbols = Join-Path $script:ArtifactsRoot "package\symbols\$architecture" - if (Test-Path -LiteralPath $architectureSymbols) { - Remove-Item -Recurse -Force -LiteralPath $architectureSymbols - } - New-Item -ItemType Directory -Force -Path $architectureSymbols | Out-Null - foreach ($moduleName in $script:ModuleNames) { - $stage = Join-Path $script:ArtifactsRoot "package\$architecture\$moduleName" - if (Test-Path -LiteralPath $stage) { - Remove-Item -Recurse -Force -LiteralPath $stage - } - $docsDirectory = Join-Path $stage 'docs' - $thirdPartyDirectory = Join-Path $docsDirectory 'thirdparty' - New-Item -ItemType Directory -Force -Path $thirdPartyDirectory | Out-Null - Copy-Item -LiteralPath (Join-Path $binaryDirectory "$moduleName.so") -Destination $stage - Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot "src\modules\$moduleName\observer_user.ini") -Destination $stage - Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot 'LICENSE.txt') -Destination (Join-Path $docsDirectory 'license.txt') - foreach ($licenseFile in $moduleLicenseFiles[$moduleName]) { - $licensePath = Join-Path $script:RepositoryRoot "licenses\$licenseFile" - if (-not (Test-Path -LiteralPath $licensePath -PathType Leaf)) { - throw "Required third-party license is missing for ${moduleName}: $licensePath" - } - Copy-Item -LiteralPath $licensePath -Destination $thirdPartyDirectory - } - - $moduleArchive = Join-Path $packagesDirectory "$moduleName-$date-$architecture-dll.zip" - if (Test-Path -LiteralPath $moduleArchive) { - Remove-Item -Force -LiteralPath $moduleArchive - } - Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $moduleArchive -CompressionLevel Optimal - [void](Assert-ObserverModulePackageManifest -ArchivePath $moduleArchive -ModuleName $moduleName) - $extractedPackage = Expand-ObserverModulePackageForSmoke ` - -ArchivePath $moduleArchive ` - -DestinationPath (Join-Path $packageSmokeRunRoot "$architecture\$moduleName") ` - -ModuleName $moduleName - - $runtimeResult = 'deferred' - if (Test-CanRunArchitecture -Architecture $architecture) { - Write-Step "Running package smoke for $moduleName $architecture" - $smokeReport = Join-Path $packageSmokeReportDirectory "$moduleName-$architecture-release.xml" - Invoke-ObserverPackageRuntimeSmoke ` - -TestExecutablePath (Join-Path $binaryDirectory 'tests.exe') ` - -ModulePath (Join-Path $extractedPackage "$moduleName.so") ` - -ModuleName $moduleName ` - -WorkingDirectory $extractedPackage ` - -ReportPath $smokeReport - $runtimeResult = 'passed' - } - else { - Write-Host "Deferred package runtime smoke for $moduleName ${architecture}: the current host cannot load $architecture binaries." - } - - $packageEvidence.Add([pscustomobject]@{ - Kind = 'module' - Architecture = $architecture - Module = $moduleName - Archive = [System.IO.Path]::GetFullPath($moduleArchive) - SHA256 = (Get-FileHash -LiteralPath $moduleArchive -Algorithm SHA256).Hash - ExtractedDirectory = $extractedPackage - RuntimeSmoke = $runtimeResult - }) - Copy-Item -LiteralPath (Join-Path $binaryDirectory "$moduleName.pdb") -Destination $architectureSymbols - Write-Host "Created and validated $moduleArchive" - } - - $symbolsArchive = Join-Path $packagesDirectory "observer-modules-$date-$architecture-pdb.zip" - if (Test-Path -LiteralPath $symbolsArchive) { - Remove-Item -Force -LiteralPath $symbolsArchive - } - Compress-Archive -Path (Join-Path $architectureSymbols '*') -DestinationPath $symbolsArchive -CompressionLevel Optimal - [void](Assert-ObserverSymbolsPackageManifest -ArchivePath $symbolsArchive) - $extractedSymbols = Expand-ObserverSymbolsPackageForSmoke ` - -ArchivePath $symbolsArchive ` - -DestinationPath (Join-Path $packageSmokeRunRoot "$architecture\symbols") - $packageEvidence.Add([pscustomobject]@{ - Kind = 'symbols' - Architecture = $architecture - Module = $null - Archive = [System.IO.Path]::GetFullPath($symbolsArchive) - SHA256 = (Get-FileHash -LiteralPath $symbolsArchive -Algorithm SHA256).Hash - ExtractedDirectory = $extractedSymbols - RuntimeSmoke = 'not-applicable' - }) - Write-Host "Created and validated $symbolsArchive" - } - - $evidencePath = Join-Path $packagesDirectory 'package-smoke-evidence.json' - $packageEvidence | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $evidencePath -Encoding utf8NoBOM - Write-Host "Package smoke evidence: $evidencePath" -} diff --git a/build/lib/verify-routing.ps1 b/build/lib/verify-routing.ps1 deleted file mode 100644 index da55aff..0000000 --- a/build/lib/verify-routing.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Get-CurrentVerifyHostArchitecture { - $hostArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() - if ($hostArchitecture -notin @('x86', 'x64', 'arm64')) { - throw "The current host architecture is unsupported: $hostArchitecture" - } - return $hostArchitecture -} - -function Test-VerifyArchitectureRunnable { - param( - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $HostArchitecture, - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $TargetArchitecture - ) - - switch ($HostArchitecture) { - 'x86' { return $TargetArchitecture -eq 'x86' } - 'x64' { return $TargetArchitecture -in @('x86', 'x64') } - 'arm64' { return $TargetArchitecture -in @('x86', 'x64', 'arm64') } - } -} - -function Get-VerifyRoutingPlan { - param( - [Parameter(Mandatory)][ValidateSet('x86', 'x64', 'arm64')][string] $HostArchitecture, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $RequestedArchitectures - ) - - $knownArchitectures = @('x86', 'x64', 'arm64') - $requested = @($RequestedArchitectures | Select-Object -Unique) - $unsupported = @($requested | Where-Object { $_ -notin $knownArchitectures }) - if ($unsupported.Count -ne 0) { - throw "Unsupported verify architecture(s): $($unsupported -join ', ')" - } - - $builds = [System.Collections.Generic.List[object]]::new() - $testRuns = [System.Collections.Generic.List[object]]::new() - $specialistGates = [System.Collections.Generic.List[object]]::new() - $packageRuntimeArchitectures = [System.Collections.Generic.List[string]]::new() - $deferred = [System.Collections.Generic.List[object]]::new() - - foreach ($architecture in $requested) { - foreach ($configuration in @('Debug', 'Release')) { - $builds.Add([pscustomobject]@{ - Architecture = $architecture - Configuration = $configuration - }) - if (Test-VerifyArchitectureRunnable -HostArchitecture $HostArchitecture -TargetArchitecture $architecture) { - $testRuns.Add([pscustomobject]@{ - Architecture = $architecture - Configuration = $configuration - }) - } - } - - if (Test-VerifyArchitectureRunnable -HostArchitecture $HostArchitecture -TargetArchitecture $architecture) { - $packageRuntimeArchitectures.Add($architecture) - } else { - $deferred.Add([pscustomobject]@{ - Gate = 'tests' - Architecture = $architecture - Reason = "The $HostArchitecture host cannot execute $architecture test binaries; use a native $architecture runner." - }) - $deferred.Add([pscustomobject]@{ - Gate = 'package-runtime' - Architecture = $architecture - Reason = "The $HostArchitecture host can validate $architecture package contents but cannot load its DLL." - }) - } - } - - if ('x64' -in $requested) { - foreach ($name in @('coverage', 'ubsan', 'leaks', 'formats')) { - $specialistGates.Add([pscustomobject]@{ Name = $name; Architecture = 'x64' }) - } - } - foreach ($architecture in @('x86', 'x64')) { - if ($architecture -in $requested) { - $specialistGates.Add([pscustomobject]@{ Name = 'asan'; Architecture = $architecture }) - } - } - - return [pscustomobject]@{ - RequestedArchitectures = @($requested) - Builds = @($builds) - TestRuns = @($testRuns) - SpecialistGates = @($specialistGates) - PackageContentArchitectures = @($requested) - PackageRuntimeArchitectures = @($packageRuntimeArchitectures) - Deferred = @($deferred) - } -} diff --git a/build/lib/verify.ps1 b/build/lib/verify.ps1 deleted file mode 100644 index 3aa1c27..0000000 --- a/build/lib/verify.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest - -function Invoke-Verify { - param( - [Parameter(Mandatory)][string[]] $Architectures, - [Parameter()][string] $CorpusPath, - [Parameter(Mandatory)][double] $RequiredCoverageThreshold, - [Parameter(Mandatory)][int] $RequiredFuzzSeconds, - [Parameter(Mandatory)][int] $RequiredLeakWarmup, - [Parameter(Mandatory)][int] $RequiredLeakIterations, - [Parameter(Mandatory)][int] $RequiredLeakWindows, - [Parameter(Mandatory)][int64] $RequiredLeakToleranceBytes - ) - - $plan = Get-VerifyRoutingPlan ` - -HostArchitecture (Get-CurrentVerifyHostArchitecture) ` - -RequestedArchitectures $Architectures - - Invoke-Restore -Architectures $plan.RequestedArchitectures - Invoke-Lint -Architectures $plan.RequestedArchitectures - - foreach ($buildGroup in @($plan.Builds | Group-Object Configuration)) { - $buildArchitectures = @($buildGroup.Group | ForEach-Object Architecture) - Invoke-Build -Architectures $buildArchitectures -Configuration $buildGroup.Name - } - foreach ($testRun in @($plan.TestRuns)) { - Invoke-TestExecutable ` - -Architecture $testRun.Architecture ` - -Configuration $testRun.Configuration ` - -CorpusPath $CorpusPath - } - - Invoke-CodeAnalysis -Architectures $plan.RequestedArchitectures - - foreach ($gateGroup in @($plan.SpecialistGates | Group-Object Name)) { - $gateArchitectures = @($gateGroup.Group | ForEach-Object Architecture) - switch ($gateGroup.Name) { - 'coverage' { - Invoke-Coverage ` - -Architectures $gateArchitectures ` - -CorpusPath $CorpusPath ` - -Threshold $RequiredCoverageThreshold - } - 'asan' { Invoke-ASan -Architectures $gateArchitectures } - 'ubsan' { Invoke-Ubsan -Architectures $gateArchitectures } - 'leaks' { - Invoke-LeakTest ` - -Architectures $gateArchitectures ` - -Warmup $RequiredLeakWarmup ` - -Iterations $RequiredLeakIterations ` - -Windows $RequiredLeakWindows ` - -ToleranceBytes $RequiredLeakToleranceBytes - } - 'formats' { - Invoke-Fuzz ` - -Architectures $gateArchitectures ` - -Seconds $RequiredFuzzSeconds ` - -TargetName 'all' - } - default { throw "Unknown verify specialist gate: $($gateGroup.Name)" } - } - } - - Invoke-Package -Architectures $plan.PackageContentArchitectures - - if ($plan.Deferred.Count -ne 0) { - Write-Step 'Runtime checks deferred to native runners' - foreach ($deferred in @($plan.Deferred)) { - Write-Host "[DEFERRED] $($deferred.Gate) $($deferred.Architecture): $($deferred.Reason)" - } - } - Write-Host "[OK] Verify completed every host-capable gate; deferred native checks: $($plan.Deferred.Count)." -} diff --git a/build/native_graph.py b/build/native_graph.py deleted file mode 100644 index 42abb23..0000000 --- a/build/native_graph.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Typed expansion for the fine-grained Windows native and analysis DAG. - -The module only describes normalized graph nodes. Process execution remains in the -outer graph driver, and all compile/link work remains in the checked-in MSBuild -projects. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from hashlib import sha256 -import json -from pathlib import Path, PurePosixPath -import re -import xml.etree.ElementTree as ET - - -MSBUILD_NAMESPACE = "http://schemas.microsoft.com/developer/msbuild/2003" -PROJECT_NAMES = ( - "renpy", - "rpgmaker", - "zanzarah", - "tests", - "fuzz-pickle", - "fuzz-renpy", - "fuzz-rpgmaker", - "fuzz-zanzarah", - "leak-probe", -) -RUNTIME_PROJECT_NAMES = ("renpy", "rpgmaker", "zanzarah", "tests") -FUZZ_PROJECT_NAMES = ("fuzz-pickle", "fuzz-renpy", "fuzz-rpgmaker", "fuzz-zanzarah") -FUZZ_TARGET_NAMES = tuple(name.removeprefix("fuzz-") for name in FUZZ_PROJECT_NAMES) -RUNTIME_CONFIGURATIONS = ("Debug", "Release", "Coverage", "ASan", "UBSan") - - -class NativeGraphError(ValueError): - """Raised when the checked-in native project manifest is not reviewable.""" - - -@dataclass(frozen=True) -class TranslationUnit: - project: str - source: PurePosixPath - slug: str - - @property - def key(self) -> str: - return f"{self.project}:{self.source.as_posix()}" - - -@dataclass(frozen=True) -class Project: - name: str - project_file: PurePosixPath - translation_units: tuple[TranslationUnit, ...] - - -@dataclass(frozen=True) -class Manifest: - workspace: Path - projects: tuple[Project, ...] - - @property - def translation_units(self) -> tuple[TranslationUnit, ...]: - return tuple(unit for project in self.projects for unit in project.translation_units) - - -def _translation_unit_slug(source: PurePosixPath) -> str: - readable = source.as_posix().removeprefix("src/").removesuffix(".cpp") - readable = re.sub(r"[^a-z0-9]+", "-", readable.lower()).strip("-") - digest = sha256(source.as_posix().encode("utf-8")).hexdigest()[:8] - return f"{readable}-{digest}" - - -def _repository_source(include: str, workspace: Path, project_name: str) -> PurePosixPath: - prefix = "$(RepositoryRoot)" - if not include.startswith(prefix): - raise NativeGraphError( - f"{project_name} ClCompile path must start with {prefix!r}: {include!r}" - ) - source = PurePosixPath(include.removeprefix(prefix).replace("\\", "/")) - if source.is_absolute() or ".." in source.parts or source.suffix.lower() != ".cpp": - raise NativeGraphError(f"unsafe {project_name} translation unit path: {source}") - if not (workspace / Path(*source.parts)).is_file(): - raise NativeGraphError(f"missing {project_name} translation unit: {source}") - return source - - -def load_manifest( - workspace: Path, *, project_names: tuple[str, ...] = PROJECT_NAMES -) -> Manifest: - resolved_workspace = workspace.resolve() - unknown = [name for name in project_names if name not in PROJECT_NAMES] - if unknown: - raise NativeGraphError(f"unknown project(s): {', '.join(unknown)}") - if len(set(project_names)) != len(project_names): - raise NativeGraphError("project manifest contains duplicate names") - - projects: list[Project] = [] - for project_name in project_names: - project_path = resolved_workspace / "build" / "projects" / f"{project_name}.vcxproj" - if not project_path.is_file(): - raise NativeGraphError(f"missing project: {project_path}") - try: - root = ET.parse(project_path).getroot() - except ET.ParseError as error: - raise NativeGraphError(f"invalid MSBuild XML in {project_path}: {error}") from error - - sources = tuple( - _repository_source(element.attrib["Include"], resolved_workspace, project_name) - for element in root.findall(f".//{{{MSBUILD_NAMESPACE}}}ClCompile") - if "Include" in element.attrib - ) - if not sources: - raise NativeGraphError(f"project has no ClCompile items: {project_name}") - if len(set(sources)) != len(sources): - raise NativeGraphError(f"project has duplicate ClCompile items: {project_name}") - units = tuple( - TranslationUnit(project_name, source, _translation_unit_slug(source)) - for source in sources - ) - projects.append( - Project( - project_name, - PurePosixPath("build", "projects", f"{project_name}.vcxproj"), - units, - ) - ) - return Manifest(resolved_workspace, tuple(projects)) - - -def _leaf_argv(action: str, *arguments: str) -> list[str]: - return [ - "pwsh", - "-NoLogo", - "-NoProfile", - "-File", - "build.ps1", - "graph-leaf", - "-GraphLeafAction", - action, - *arguments, - ] - - -def fuzz_build_node_name(target: str) -> str: - """Return the single build-node identity shared with the dynamic fuzz graph.""" - - if target not in FUZZ_TARGET_NAMES: - raise NativeGraphError(f"unknown fuzz target: {target}") - return f"build-fuzz-{target}" - - -def build_project_node_name(configuration: str, project_name: str) -> str: - """Return the build-node identity shared by native and dynamic graph sections.""" - - supported = ( - project_name in RUNTIME_PROJECT_NAMES - and configuration in RUNTIME_CONFIGURATIONS - ) or (project_name == "leak-probe" and configuration == "Release") - if not supported: - raise NativeGraphError( - f"unsupported project build: {configuration}/{project_name}" - ) - return f"build-{configuration.lower()}-{project_name}" - - -def _node( - name: str, - *, - deps: list[str], - resources: dict[str, int], - argv: list[str], - inputs: list[str], - outputs: list[str], - writes: list[str], -) -> dict[str, object]: - return { - "name": name, - "deps": deps, - "run_after": [], - "resources": resources, - "argv": argv, - "inputs": inputs, - "outputs": outputs, - "writes": writes, - "fingerprint": ["contract=native-microdag-v1", f"node={name}"], - "cacheable": False, - } - - -def _binary_writes(configuration: str, project_name: str) -> list[str]: - root = f".artifacts/bin/x64/{configuration}" - object_root = f".artifacts/obj/x64/{configuration}/{project_name}/" - if project_name in ("tests", "leak-probe") or project_name.startswith("fuzz-"): - return [object_root, f"{root}/{project_name}.exe", f"{root}/{project_name}.pdb"] - return [ - object_root, - f"{root}/{project_name}.so", - f"{root}/{project_name}.pdb", - f"{root}/{project_name}.lib", - f"{root}/{project_name}.exp", - ] - - -def _build_node(configuration: str, project_name: str) -> dict[str, object]: - if configuration == "Fuzz": - name = fuzz_build_node_name(project_name.removeprefix("fuzz-")) - else: - name = build_project_node_name(configuration, project_name) - binary_extension = ( - ".exe" - if project_name in ("tests", "leak-probe") or project_name.startswith("fuzz-") - else ".so" - ) - return _node( - name, - deps=["source-checks"], - resources={"cpu": 4, "memory-gib": 2, "native-msbuild": 1}, - argv=_leaf_argv( - "build-project", - "-Arch", - "x64", - "-Config", - configuration, - "-Project", - project_name, - ), - inputs=[ - "build/**/*.props", - f"build/projects/{project_name}.vcxproj", - "src/**/*.cpp", - "src/**/*.h", - "vcpkg.json", - ], - outputs=[f".artifacts/bin/x64/{configuration}/{project_name}{binary_extension}"], - writes=_binary_writes(configuration, project_name), - ) - - -def _test_node(configuration: str) -> dict[str, object]: - config_key = configuration.lower() - report = f".artifacts/reports/tests/tests-x64-{config_key}.xml" - return _node( - f"run-tests-{config_key}", - deps=sorted(f"build-{config_key}-{project}" for project in RUNTIME_PROJECT_NAMES), - resources={"cpu": 1, "test-run": 1}, - argv=_leaf_argv("run-tests", "-Arch", "x64", "-Config", configuration), - inputs=[f".artifacts/bin/x64/{configuration}/*"], - outputs=[report], - writes=[report], - ) - - -def _msvc_analysis_nodes(unit: TranslationUnit) -> tuple[dict[str, object], dict[str, object]]: - configuration = "Release" if unit.project == "leak-probe" else "Debug" - node_suffix = f"{unit.project}-{unit.slug}" - scratch = f".artifacts/analysis/msvc/x64/{unit.project}/{unit.slug}/" - raw = f"{scratch}{unit.project}.sarif" - normalized = f".artifacts/reports/msvc/x64/units/{node_suffix}.sarif" - analyze_name = f"analyze-msvc-{node_suffix}" - analyze = _node( - analyze_name, - deps=["source-checks"], - resources={"cpu": 1, "memory-gib": 2, "msvc-analysis": 1}, - argv=_leaf_argv( - "analyze-msvc-project", - "-Arch", - "x64", - "-Config", - configuration, - "-Project", - unit.project, - "-SelectedFile", - unit.source.as_posix(), - "-Unit", - unit.slug, - ), - inputs=[ - "build/ObserverNativeAnalysis.ruleset", - "build/**/*.props", - f"build/projects/{unit.project}.vcxproj", - unit.source.as_posix(), - ], - outputs=[raw], - writes=[scratch], - ) - normalize = _node( - f"normalize-msvc-{node_suffix}", - deps=[analyze_name], - resources={"cpu": 1, "sarif": 1}, - argv=_leaf_argv( - "normalize-msvc-sarif", - "-Arch", - "x64", - "-Project", - unit.project, - "-Unit", - unit.slug, - "-Input", - raw, - "-Output", - normalized, - ), - inputs=[raw], - outputs=[normalized], - writes=[normalized], - ) - return analyze, normalize - - -def _tidy_analysis_nodes(unit: TranslationUnit) -> tuple[dict[str, object], dict[str, object]]: - node_suffix = f"{unit.project}-{unit.slug}" - scratch = f".artifacts/analysis/clang-tidy/x64/{unit.project}/{unit.slug}/" - object_root = f"{scratch}obj/" - raw_log = f"{object_root}{unit.project}.ClangTidy.log" - normalized = f".artifacts/reports/clang-tidy/x64/units/{node_suffix}.sarif" - analyze_name = f"analyze-tidy-{node_suffix}" - analyze = _node( - analyze_name, - deps=["source-checks"], - resources={"clang-tidy": 1, "cpu": 1}, - argv=_leaf_argv( - "analyze-tidy-unit", - "-Arch", - "x64", - "-Project", - unit.project, - "-SelectedFile", - unit.source.as_posix(), - "-Unit", - unit.slug, - ), - inputs=[ - ".clang-tidy", - "build/**/*.props", - f"build/projects/{unit.project}.vcxproj", - unit.source.as_posix(), - ], - outputs=[raw_log], - writes=[scratch], - ) - normalize = _node( - f"normalize-tidy-{node_suffix}", - deps=[analyze_name], - resources={"cpu": 1, "sarif": 1}, - argv=_leaf_argv( - "normalize-tidy-sarif", - "-Arch", - "x64", - "-Project", - unit.project, - "-Unit", - unit.slug, - "-InputRoot", - object_root, - "-Output", - normalized, - ), - inputs=[raw_log], - outputs=[normalized], - writes=[normalized], - ) - return analyze, normalize - - -def expand_x64_nodes(manifest: Manifest) -> list[dict[str, object]]: - """Return schema-v2 normalized records; profile-level roots are added by the caller.""" - - nodes: list[dict[str, object]] = [] - for configuration in RUNTIME_CONFIGURATIONS: - nodes.extend(_build_node(configuration, project) for project in RUNTIME_PROJECT_NAMES) - nodes.append(_test_node(configuration)) - for project in FUZZ_PROJECT_NAMES: - nodes.append(_build_node("Fuzz", project)) - nodes.append(_build_node("Release", "leak-probe")) - - msvc_normalizers: list[str] = [] - msvc_reports: list[str] = [] - for unit in manifest.translation_units: - analyze, normalize = _msvc_analysis_nodes(unit) - nodes.extend((analyze, normalize)) - msvc_normalizers.append(str(normalize["name"])) - msvc_reports.append(str(normalize["outputs"][0])) - - tidy_normalizers: list[str] = [] - tidy_reports: list[str] = [] - for unit in manifest.translation_units: - analyze, normalize = _tidy_analysis_nodes(unit) - nodes.extend((analyze, normalize)) - tidy_normalizers.append(str(normalize["name"])) - tidy_reports.append(str(normalize["outputs"][0])) - - msvc_merged = ".artifacts/reports/msvc/x64/msvc-analyze.sarif" - tidy_merged = ".artifacts/reports/clang-tidy/x64/clang-tidy.sarif" - nodes.append( - _node( - "merge-msvc-sarif", - deps=msvc_normalizers, - resources={"cpu": 1, "sarif": 1}, - argv=_leaf_argv( - "merge-sarif", - "-Arch", - "x64", - "-Backend", - "msvc", - "-InputPathsJson", - json.dumps(msvc_reports, separators=(",", ":")), - "-Output", - msvc_merged, - ), - inputs=msvc_reports, - outputs=[msvc_merged], - writes=[msvc_merged], - ) - ) - nodes.append( - _node( - "merge-tidy-sarif", - deps=tidy_normalizers, - resources={"cpu": 1, "sarif": 1}, - argv=_leaf_argv( - "merge-sarif", - "-Arch", - "x64", - "-Backend", - "clang-tidy", - "-InputPathsJson", - json.dumps(tidy_reports, separators=(",", ":")), - "-Output", - tidy_merged, - ), - inputs=tidy_reports, - outputs=[tidy_merged], - writes=[tidy_merged], - ) - ) - gate_report = ".artifacts/reports/analysis/x64/gate.json" - nodes.append( - _node( - "analysis-gate", - deps=["merge-msvc-sarif", "merge-tidy-sarif"], - resources={"cpu": 1, "sarif": 1}, - argv=_leaf_argv( - "analysis-gate", - "-Arch", - "x64", - "-MsvcReport", - msvc_merged, - "-ClangTidyReport", - tidy_merged, - ), - inputs=[msvc_merged, tidy_merged], - outputs=[gate_report], - writes=[gate_report], - ) - ) - return nodes diff --git a/build/run-msbuild.cmd b/build/run-msbuild.cmd deleted file mode 100644 index 482d0a9..0000000 --- a/build/run-msbuild.cmd +++ /dev/null @@ -1,12 +0,0 @@ -@echo off -setlocal -set "OBSERVER_CLEAN_PATH=%PATH%" -set "OBSERVER_CLEAN_LIB=%LIB%" -set "Path=" -set "PATH=" -set "Lib=" -set "LIB=" -set "PATH=%OBSERVER_CLEAN_PATH%" -set "LIB=%OBSERVER_CLEAN_LIB%" -"%OBSERVER_MSBUILD_EXE%" %* -exit /b %ERRORLEVEL% diff --git a/build/tests/analysis-reporting.Tests.ps1 b/build/tests/analysis-reporting.Tests.ps1 deleted file mode 100644 index 3d014f4..0000000 --- a/build/tests/analysis-reporting.Tests.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$modulePath = Join-Path $repositoryRoot 'build\lib\analysis-reporting.ps1' -if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { - throw "Analysis-reporting production module was not found: $modulePath" -} -. $modulePath - -function Assert-Equal { - param( - [Parameter(Mandatory)][AllowNull()] $Actual, - [Parameter(Mandatory)][AllowNull()] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if (-not [object]::Equals($Actual, $Expected)) { - throw "${Description}: actual='$Actual', expected='$Expected'." - } -} - -$testRoot = Join-Path $repositoryRoot ".artifacts\contract-tests\analysis-reporting-$([guid]::NewGuid().ToString('N'))" -$objectRoot = Join-Path $testRoot 'obj\x64' -$reportPath = Join-Path $testRoot 'reports\clang-tidy\x64\clang-tidy.sarif' - -try { - $renpyLogDirectory = Join-Path $objectRoot 'Debug\renpy' - $testsLogDirectory = Join-Path $objectRoot 'Debug\tests' - New-Item -ItemType Directory -Force -Path $renpyLogDirectory, $testsLogDirectory | Out-Null - - $apiPath = Join-Path $repositoryRoot 'src\api.h' - $archivePath = Join-Path $repositoryRoot 'src\archive.cpp' - $outsidePath = Join-Path (Split-Path $repositoryRoot -Parent) 'dependency\upstream.cpp' - @( - "[1/2] Processing file $apiPath." - "${apiPath}(28,9): error : declaration uses a reserved identifier [bugprone-reserved-identifier,-warnings-as-errors] [$repositoryRoot\build\projects\renpy.vcxproj]" - "${archivePath}(41,7): warning : prefer a scoped lock [bugprone-lock-mutex] [$repositoryRoot\build\projects\renpy.vcxproj]" - "${archivePath}(41,7): message : diagnostic note without an independent rule [$repositoryRoot\build\projects\renpy.vcxproj]" - "${outsidePath}(5,2): warning : dependency warning [bugprone-example] [$repositoryRoot\build\projects\renpy.vcxproj]" - 'Suppressed 123 warnings (123 in non-user code).' - ) | Set-Content -LiteralPath (Join-Path $renpyLogDirectory 'renpy.ClangTidy.log') -Encoding utf8 - @( - "${apiPath}(28,9): error : declaration uses a reserved identifier [bugprone-reserved-identifier,-warnings-as-errors] [$repositoryRoot\build\projects\tests.vcxproj]" - ) | Set-Content -LiteralPath (Join-Path $testsLogDirectory 'tests.ClangTidy.log') -Encoding utf8 - - Export-ClangTidySarif ` - -RepositoryRoot $repositoryRoot ` - -ObjectRoot $objectRoot ` - -OutputPath $reportPath ` - -Architecture 'x64' - - if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) { - throw "Clang-tidy SARIF was not created: $reportPath" - } - $sarif = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json - Assert-Equal -Actual $sarif.version -Expected '2.1.0' -Description 'SARIF version' - Assert-Equal -Actual @($sarif.runs).Count -Expected 1 -Description 'SARIF run count' - - $run = @($sarif.runs)[0] - Assert-Equal -Actual $run.tool.driver.name -Expected 'clang-tidy' -Description 'SARIF driver name' - Assert-Equal -Actual $run.automationDetails.id -Expected 'clang-tidy/x64/' -Description 'Stable run identity' - Assert-Equal -Actual @($run.results).Count -Expected 2 -Description 'Deduplicated first-party result count' - - $results = @($run.results | Sort-Object ruleId) - Assert-Equal -Actual $results[0].ruleId -Expected 'bugprone-lock-mutex' -Description 'Warning rule ID' - Assert-Equal -Actual $results[0].level -Expected 'warning' -Description 'Warning SARIF level' - Assert-Equal -Actual $results[0].locations[0].physicalLocation.artifactLocation.uri -Expected 'src/archive.cpp' -Description 'Normalized warning URI' - Assert-Equal -Actual $results[1].ruleId -Expected 'bugprone-reserved-identifier' -Description 'Error rule ID' - Assert-Equal -Actual $results[1].level -Expected 'error' -Description 'Error SARIF level' - Assert-Equal -Actual ([int] $results[1].locations[0].physicalLocation.region.startLine) -Expected 28 -Description 'Error line' - Assert-Equal -Actual ([int] $results[1].locations[0].physicalLocation.region.startColumn) -Expected 9 -Description 'Error column' - Assert-Equal -Actual @($run.tool.driver.rules).Count -Expected 2 -Description 'Unique rule metadata count' - - $emptyObjectRoot = Join-Path $testRoot 'obj\arm64' - $emptyReportPath = Join-Path $testRoot 'reports\clang-tidy\arm64\clang-tidy.sarif' - New-Item -ItemType Directory -Force -Path $emptyObjectRoot | Out-Null - Export-ClangTidySarif ` - -RepositoryRoot $repositoryRoot ` - -ObjectRoot $emptyObjectRoot ` - -OutputPath $emptyReportPath ` - -Architecture 'arm64' - $emptySarif = Get-Content -Raw -LiteralPath $emptyReportPath | ConvertFrom-Json - Assert-Equal -Actual @($emptySarif.runs[0].results).Count -Expected 0 -Description 'Empty result count' - Assert-Equal -Actual $emptySarif.runs[0].automationDetails.id -Expected 'clang-tidy/arm64/' -Description 'Empty run identity' - - $msvcReportDirectory = Join-Path $testRoot 'reports\msvc\x64' - New-Item -ItemType Directory -Force -Path $msvcReportDirectory | Out-Null - foreach ($reportName in @('renpy', 'tests')) { - [ordered]@{ - version = '2.1.0' - runs = @( - [ordered]@{ - tool = [ordered]@{ driver = [ordered]@{ name = 'Microsoft C/C++ Code Analysis' } } - results = @([ordered]@{ ruleId = 'C6001' }) - } - ) - } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $msvcReportDirectory "$reportName.sarif") -Encoding utf8 - } - - Set-MSVCAnalysisSarifIdentity -ReportDirectory $msvcReportDirectory -Architecture 'x64' - foreach ($reportName in @('renpy', 'tests')) { - $report = Get-Content -Raw -LiteralPath (Join-Path $msvcReportDirectory "$reportName.sarif") | ConvertFrom-Json - Assert-Equal ` - -Actual $report.runs[0].automationDetails.id ` - -Expected "msvc-analyze/x64/$reportName/" ` - -Description "$reportName MSVC run identity" - Assert-Equal -Actual @($report.runs[0].results).Count -Expected 1 -Description "$reportName result preservation" - } -} finally { - if (Test-Path -LiteralPath $testRoot) { - $resolvedTestRoot = [System.IO.Path]::GetFullPath($testRoot) - $expectedParent = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts\contract-tests')) - if (-not $resolvedTestRoot.StartsWith($expectedParent + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to remove unexpected contract-test path: $resolvedTestRoot" - } - Remove-Item -Recurse -Force -LiteralPath $resolvedTestRoot - } -} - -Write-Host '[OK] Clang-tidy logs convert to deterministic first-party SARIF.' diff --git a/build/tests/build-entrypoint-contract.Tests.ps1 b/build/tests/build-entrypoint-contract.Tests.ps1 deleted file mode 100644 index 7ae147e..0000000 --- a/build/tests/build-entrypoint-contract.Tests.ps1 +++ /dev/null @@ -1,295 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Read-PowerShellAst { - param([Parameter(Mandatory)][string] $Path) - - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) - if ($errors.Count -ne 0) { - $messages = @($errors | ForEach-Object Message) - throw "PowerShell parse failed for '$Path': $($messages -join '; ')" - } - return $ast -} - -function Assert-SequenceEqual { - param( - [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Actual, - [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if ($Actual.Count -ne $Expected.Count) { - throw "$Description count differs: actual=$($Actual.Count), expected=$($Expected.Count)." - } - for ($index = 0; $index -lt $Expected.Count; ++$index) { - if (-not [object]::Equals($Actual[$index], $Expected[$index])) { - throw "$Description differs at index ${index}: actual='$($Actual[$index])', expected='$($Expected[$index])'." - } - } -} - -function Get-HelpOutput { - param( - [Parameter(Mandatory)][string] $PowerShellPath, - [Parameter(Mandatory)][string] $ScriptPath - ) - - $output = @(& $PowerShellPath -NoLogo -NoProfile -File $ScriptPath help 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Help command failed for '$ScriptPath' with exit code $LASTEXITCODE." - } - return ((@($output | ForEach-Object { $_.ToString() }) -join "`n") -replace "`r`n?", "`n").TrimEnd() -} - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$rootEntrypoint = Join-Path $repositoryRoot 'build.ps1' -$internalEntrypoint = Join-Path $repositoryRoot 'build\build.ps1' -$commonLibrary = Join-Path $repositoryRoot 'build\lib\common.ps1' -$powerShellPath = Join-Path $PSHOME 'pwsh.exe' - -$rootText = Get-Content -Raw -LiteralPath $rootEntrypoint -$expectedForwarder = "& (Join-Path `$PSScriptRoot 'build\build.ps1') @args" -if (-not $rootText.Contains($expectedForwarder, [StringComparison]::Ordinal)) { - throw 'The root build.ps1 entrypoint no longer forwards arguments unchanged.' -} - -$internalAst = Read-PowerShellAst -Path $internalEntrypoint -$commandParameter = @( - $internalAst.ParamBlock.Parameters | - Where-Object { $_.Name.VariablePath.UserPath -eq 'Command' } -) -if ($commandParameter.Count -ne 1) { - throw 'The internal entrypoint must expose exactly one Command parameter.' -} -$validateSet = @( - $commandParameter[0].Attributes | - Where-Object { $_.TypeName.FullName -eq 'ValidateSet' } -) -if ($validateSet.Count -ne 1) { - throw 'The Command parameter must retain one ValidateSet contract.' -} -$actualCommands = @($validateSet[0].PositionalArguments | ForEach-Object { $_.SafeGetValue() }) -$expectedCommands = @( - 'help', - 'doctor', - 'restore', - 'build', - 'test', - 'source-checks', - 'compiler-analysis', - 'test-coverage', - 'test-asan', - 'test-ubsan', - 'test-leaks', - 'fuzz', - 'audit-binaries', - 'package', - 'verify', - 'clean' -) -Assert-SequenceEqual -Actual $actualCommands -Expected $expectedCommands -Description 'Command ValidateSet' - -$restoreFlavorParameter = @( - $internalAst.ParamBlock.Parameters | - Where-Object { $_.Name.VariablePath.UserPath -eq 'RestoreFlavor' } -) -if ($restoreFlavorParameter.Count -ne 1) { - throw 'The internal entrypoint must expose exactly one RestoreFlavor parameter.' -} -$restoreFlavorValidateSet = @( - $restoreFlavorParameter[0].Attributes | - Where-Object { $_.TypeName.FullName -eq 'ValidateSet' } -) -if ($restoreFlavorValidateSet.Count -ne 1) { - throw 'RestoreFlavor must retain one ValidateSet contract.' -} -Assert-SequenceEqual ` - -Actual @($restoreFlavorValidateSet[0].PositionalArguments | ForEach-Object { $_.SafeGetValue() }) ` - -Expected @('default', 'asan', 'all') ` - -Description 'RestoreFlavor ValidateSet' - -$skipRestoreParameter = @( - $internalAst.ParamBlock.Parameters | - Where-Object { $_.Name.VariablePath.UserPath -eq 'SkipDependencyRestore' } -) -if ($skipRestoreParameter.Count -ne 1 -or - $skipRestoreParameter[0].StaticType -ne [System.Management.Automation.SwitchParameter]) { - throw 'The internal entrypoint must expose one SkipDependencyRestore switch.' -} - -$expectedHelp = @' -ObserverModules build entry point - - doctor inspect the complete toolchain - restore -Arch restore pinned static vcpkg dependencies - build -Arch build modules and tests - test -Arch build and run deterministic/corpus tests - source-checks -Arch clang-format, Cppcheck, PSScriptAnalyzer - compiler-analysis -Arch MSVC /analyze plus clang-tidy - test-coverage -Arch run tests and enforce llvm-cov source coverage - test-asan -Arch build dependencies/code and run tests with MSVC ASan - test-ubsan -Arch x64 build and run tests with clang-cl UBSan - test-leaks -Arch x64 run UMDH operation and DLL-lifecycle leak checks - fuzz -Arch x64 build and run one or all libFuzzer targets - audit-binaries -Arch build and inspect Release PE files with dumpbin - package -Arch module ZIPs plus one combined PDB ZIP - verify -Arch complete host-capable gate with explicit native deferrals - clean remove .artifacts - -Options: - -Config Debug|Release - -Corpus - -RestoreFlavor default|asan|all restore only; "all" prepares serial DAG dependency flavors - -SkipDependencyRestore DAG leaf only; dependencies must already be restored - -FuzzSeconds - -FuzzTarget pickle, renpy, rpgmaker, zanzarah, or all (default) - -LeakWarmup - -LeakIterations - -LeakWindows <3..10> - -LeakToleranceBytes defaults to zero; use only for a reviewed stack-specific exception - -CoverageThreshold <0..100> defaults to 100 for source lines and branches -'@.TrimEnd() -$rootHelp = Get-HelpOutput -PowerShellPath $powerShellPath -ScriptPath $rootEntrypoint -$internalHelp = Get-HelpOutput -PowerShellPath $powerShellPath -ScriptPath $internalEntrypoint -if (-not $rootHelp.Equals($expectedHelp, [StringComparison]::Ordinal)) { - throw 'The public root help output changed.' -} -if (-not $internalHelp.Equals($expectedHelp, [StringComparison]::Ordinal)) { - throw 'The internal help output differs from the public CLI contract.' -} - -$entrypointText = Get-Content -Raw -LiteralPath $internalEntrypoint -$dotSourceMatches = [regex]::Matches( - $entrypointText, - "(?m)^\.\s+\(Join-Path\s+\`$script:BuildRoot\s+'lib\\([^']+)'\)\s*`$" -) -$actualLoadOrder = @($dotSourceMatches | ForEach-Object { $_.Groups[1].Value }) -Assert-SequenceEqual ` - -Actual $actualLoadOrder ` - -Expected @( - 'package-manifest.ps1', - 'analysis-reporting.ps1', - 'common.ps1', - 'package-smoke.ps1', - 'verify-routing.ps1', - 'packaging.ps1', - 'verify.ps1' - ) ` - -Description 'Entrypoint library load order' - -if (-not (Test-Path -LiteralPath $commonLibrary -PathType Leaf)) { - throw "Common build library is missing: $commonLibrary" -} -$commonAst = Read-PowerShellAst -Path $commonLibrary -$expectedCommonFunctions = @( - 'Write-Step', - 'Invoke-Native', - 'Invoke-NativeCapture', - 'Get-RequestedArchitecture', - 'Get-MSBuildPlatform', - 'Get-VcpkgTriplet', - 'Get-BinaryDirectory', - 'Resolve-UserPath' -) -$actualCommonFunctions = @( - $commonAst.FindAll( - { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, - $false - ) | - ForEach-Object Name -) -Assert-SequenceEqual -Actual $actualCommonFunctions -Expected $expectedCommonFunctions -Description 'Common helper surface' - -$entrypointFunctionNames = @( - $internalAst.FindAll( - { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, - $false - ) | - ForEach-Object Name -) -$duplicateCommonFunctions = @($expectedCommonFunctions | Where-Object { $_ -in $entrypointFunctionNames }) -if ($duplicateCommonFunctions.Count -ne 0) { - throw "Common helpers remain duplicated in the entrypoint: $($duplicateCommonFunctions -join ', ')." -} - -$restoreFunction = @( - $internalAst.FindAll( - { - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq 'Invoke-Restore' - }, - $false - ) -) -if ($restoreFunction.Count -ne 1) { - throw 'The entrypoint must define exactly one Invoke-Restore function.' -} -$restoreFunctionText = $restoreFunction[0].Extent.Text -$skipGuardIndex = $restoreFunctionText.IndexOf('if ($SkipDependencyRestore)', [StringComparison]::Ordinal) -$skipReturnIndex = $restoreFunctionText.IndexOf('return', $skipGuardIndex, [StringComparison]::Ordinal) -$toolDiscoveryIndex = $restoreFunctionText.IndexOf('Resolve-Vcpkg', [StringComparison]::Ordinal) -if ($skipGuardIndex -lt 0 -or $skipReturnIndex -lt $skipGuardIndex -or $toolDiscoveryIndex -lt $skipReturnIndex) { - throw 'SkipDependencyRestore must return before vcpkg discovery and execution.' -} -if ($restoreFunctionText -notmatch "'all'\) \{ @\('default', 'asan'\) \}") { - throw 'RestoreFlavor all must expand to serial default and ASan restores.' -} - -$rejectedRestoreOutput = @( - & $powerShellPath -NoLogo -NoProfile -File $internalEntrypoint ` - restore -Arch x64 -SkipDependencyRestore 2>&1 -) -if ($LASTEXITCODE -eq 0 -or - ($rejectedRestoreOutput -join "`n") -notmatch 'cannot be used with the restore command') { - throw 'The restore command must reject SkipDependencyRestore instead of reporting a false success.' -} - -$script:BuildRoot = Join-Path $repositoryRoot 'build' -$script:RepositoryRoot = $repositoryRoot -$script:AggregateProject = Join-Path $script:BuildRoot 'ObserverModules.proj' -$script:ArtifactsRoot = Join-Path $repositoryRoot '.artifacts' -$script:KnownArchitectures = @('x86', 'x64', 'arm64') -. $commonLibrary - -Assert-SequenceEqual ` - -Actual @(Get-RequestedArchitecture -Requested @('all')) ` - -Expected @('x86', 'x64', 'arm64') ` - -Description 'All-architecture expansion' -Assert-SequenceEqual ` - -Actual @( - Get-MSBuildPlatform -Architecture 'x86' - Get-MSBuildPlatform -Architecture 'x64' - Get-MSBuildPlatform -Architecture 'arm64' - ) ` - -Expected @('Win32', 'x64', 'ARM64') ` - -Description 'MSBuild platform mapping' -Assert-SequenceEqual ` - -Actual @( - Get-VcpkgTriplet -Architecture 'x64' - Get-VcpkgTriplet -Architecture 'x64' -Flavor 'asan' - ) ` - -Expected @('observer-x64-windows-static', 'observer-x64-windows-static-asan') ` - -Description 'vcpkg triplet mapping' - -$expectedBinaryDirectory = Join-Path $script:ArtifactsRoot 'bin\x64\Release' -$actualBinaryDirectory = Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release' -if (-not $actualBinaryDirectory.Equals($expectedBinaryDirectory, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Get-BinaryDirectory no longer uses the explicit build context.' -} -$expectedUserPath = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'docs')) -$actualUserPath = Resolve-UserPath -Path 'docs' -if (-not $actualUserPath.Equals($expectedUserPath, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Resolve-UserPath no longer resolves relative to the repository root.' -} -$capture = Invoke-NativeCapture ` - -FilePath $powerShellPath ` - -Arguments @('-NoLogo', '-NoProfile', '-Command', "Write-Output 'common-capture'") -Assert-SequenceEqual -Actual @($capture) -Expected @('common-capture') -Description 'Native capture helper' - -Write-Host '[OK] Build entrypoint CLI and common-library scope contracts are stable.' diff --git a/build/tests/ci-reporting-contract.Tests.ps1 b/build/tests/ci-reporting-contract.Tests.ps1 deleted file mode 100644 index 81b455b..0000000 --- a/build/tests/ci-reporting-contract.Tests.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$workflowPath = Join-Path $repositoryRoot '.github\workflows\main.yml' -$buildScriptPath = Join-Path $repositoryRoot 'build\build.ps1' -$workflow = Get-Content -Raw -LiteralPath $workflowPath -$buildScript = Get-Content -Raw -LiteralPath $buildScriptPath - -function Assert-ContainsText { - param( - [Parameter(Mandatory)][string] $Text, - [Parameter(Mandatory)][string] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if (-not $Text.Contains($Expected, [System.StringComparison]::Ordinal)) { - throw "$Description is missing '$Expected'." - } -} - -Assert-ContainsText ` - -Text $buildScript ` - -Expected ". (Join-Path `$script:BuildRoot 'lib\analysis-reporting.ps1')" ` - -Description 'Build entry-point analysis-reporting import' - -$analysisFunctionMatch = [regex]::Match( - $buildScript, - '(?s)function Invoke-CodeAnalysis \{(?.*?)\r?\n\}\r?\n\r?\nfunction Add-ASanRuntimeToPath' -) -if (-not $analysisFunctionMatch.Success) { - throw 'Invoke-CodeAnalysis could not be isolated for reporting-contract validation.' -} -$analysisFunction = $analysisFunctionMatch.Groups['body'].Value -foreach ($requiredFragment in @( - 'try {', - '} finally {', - 'Set-MSVCAnalysisSarifIdentity', - 'Export-ClangTidySarif', - "reports\clang-tidy", - 'clang-tidy.sarif' -)) { - Assert-ContainsText -Text $analysisFunction -Expected $requiredFragment -Description 'Compiler-analysis reporting contract' -} - -$expectedUploads = @( - @{ - Name = 'Cppcheck x86' - File = '.artifacts/reports/cppcheck/cppcheck-x86.sarif' - Category = 'cppcheck/x86' - }, - @{ - Name = 'Cppcheck x64' - File = '.artifacts/reports/cppcheck/cppcheck-x64.sarif' - Category = 'cppcheck/x64' - }, - @{ - Name = 'Cppcheck ARM64' - File = '.artifacts/reports/cppcheck/cppcheck-arm64.sarif' - Category = 'cppcheck/arm64' - }, - @{ - Name = 'MSVC' - File = '.artifacts/reports/msvc/${{ matrix.arch }}' - Category = 'msvc-analyze/${{ matrix.arch }}' - }, - @{ - Name = 'clang-tidy' - File = '.artifacts/reports/clang-tidy/${{ matrix.arch }}/clang-tidy.sarif' - Category = 'clang-tidy/${{ matrix.arch }}' - }, - @{ - Name = 'BinSkim' - File = '.artifacts/audit/binskim-${{ matrix.arch }}.sarif' - Category = 'binskim/${{ matrix.arch }}' - } -) -foreach ($upload in $expectedUploads) { - Assert-ContainsText -Text $workflow -Expected "sarif_file: $($upload.File)" -Description "$($upload.Name) SARIF upload" - Assert-ContainsText -Text $workflow -Expected "category: $($upload.Category)" -Description "$($upload.Name) category" -} - -Assert-ContainsText -Text $workflow -Expected '- name: Upload clang-tidy SARIF' -Description 'clang-tidy upload step' -Assert-ContainsText -Text $workflow -Expected '.artifacts/reports/clang-tidy/${{ matrix.arch }}' -Description 'Archived clang-tidy evidence' - -foreach ($codeQlFragment in @( - '- name: Analyze and upload CodeQL SARIF', - 'category: codeql/c-cpp', - 'output: .artifacts/reports/codeql/raw', - 'post-processed-sarif-path: .artifacts/reports/codeql/uploaded', - '- name: Archive CodeQL SARIF', - 'name: codeql-sarif', - 'path: .artifacts/reports/codeql' -)) { - Assert-ContainsText -Text $workflow -Expected $codeQlFragment -Description 'CodeQL retained-report contract' -} - -$workflowSteps = [regex]::Matches( - $workflow, - '(?ms)^ - name: (?[^\r\n]+)\r?\n(?.*?)(?=^ - |^ \S|\z)' -) -$sarifUploadSteps = @( - $workflowSteps | - Where-Object { $_.Groups['body'].Value.Contains('uses: github/codeql-action/upload-sarif@v4') } -) -if ($sarifUploadSteps.Count -eq 0) { - throw 'No third-party SARIF upload steps were found.' -} -foreach ($uploadStep in $sarifUploadSteps) { - $uploadBody = $uploadStep.Groups['body'].Value - Assert-ContainsText ` - -Text $uploadBody ` - -Expected 'if: always() && hashFiles(' ` - -Description "$($uploadStep.Groups['name'].Value) report-existence guard" - Assert-ContainsText ` - -Text $uploadBody ` - -Expected "github.event.pull_request.head.repo.full_name == github.repository" ` - -Description "$($uploadStep.Groups['name'].Value) fork-permission guard" -} - -Write-Host '[OK] CI retains and uniquely categorizes Cppcheck, MSVC, clang-tidy, CodeQL, and BinSkim SARIF.' diff --git a/build/tests/compiler-analysis-graph.Tests.ps1 b/build/tests/compiler-analysis-graph.Tests.ps1 deleted file mode 100644 index 65e4092..0000000 --- a/build/tests/compiler-analysis-graph.Tests.ps1 +++ /dev/null @@ -1,253 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$namespace = [System.Xml.XmlNamespaceManager]::new([System.Xml.NameTable]::new()) -$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') - -function Read-MSBuildProject { - param([Parameter(Mandatory)][string] $Path) - - [xml] $project = Get-Content -Raw -LiteralPath $Path - return $project -} - -function Assert-SequenceEqual { - param( - [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Actual, - [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if ($Actual.Count -ne $Expected.Count) { - throw "$Description count differs: actual=$($Actual.Count), expected=$($Expected.Count)." - } - for ($index = 0; $index -lt $Expected.Count; ++$index) { - if (-not [object]::Equals($Actual[$index], $Expected[$index])) { - throw "$Description differs at index ${index}: actual='$($Actual[$index])', expected='$($Expected[$index])'." - } - } -} - -$aggregateProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverModules.proj') -$analysisProjects = @($aggregateProject.SelectNodes('//msb:AnalysisProject', $namespace)) -$expectedManifest = @( - 'renpy.vcxproj|renpy|Debug|Win32;x64;ARM64', - 'rpgmaker.vcxproj|rpgmaker|Debug|Win32;x64;ARM64', - 'zanzarah.vcxproj|zanzarah|Debug|Win32;x64;ARM64', - 'tests.vcxproj|tests|Debug|Win32;x64;ARM64', - 'fuzz-pickle.vcxproj|fuzz-pickle|Debug|Win32;x64;ARM64', - 'fuzz-renpy.vcxproj|fuzz-renpy|Debug|Win32;x64;ARM64', - 'fuzz-rpgmaker.vcxproj|fuzz-rpgmaker|Debug|Win32;x64;ARM64', - 'fuzz-zanzarah.vcxproj|fuzz-zanzarah|Debug|Win32;x64;ARM64', - 'leak-probe.vcxproj|leak-probe|Release|x64' -) -$actualManifest = @( - $analysisProjects | - ForEach-Object { - '{0}|{1}|{2}|{3}' -f ` - [System.IO.Path]::GetFileName($_.Include), ` - $_.AnalysisReportName, ` - $_.AnalysisConfiguration, ` - $_.AnalysisPlatforms - } -) -Assert-SequenceEqual -Actual $actualManifest -Expected $expectedManifest -Description 'Compiler-analysis project/report manifest' - -$reportNames = @($analysisProjects | ForEach-Object AnalysisReportName) -if (@($reportNames | Sort-Object -Unique).Count -ne $reportNames.Count) { - throw 'Compiler-analysis report names must be globally unique.' -} - -$expectedReportsByPlatform = @{ - Win32 = @( - 'fuzz-pickle.sarif', - 'fuzz-renpy.sarif', - 'fuzz-rpgmaker.sarif', - 'fuzz-zanzarah.sarif', - 'renpy.sarif', - 'rpgmaker.sarif', - 'tests.sarif', - 'zanzarah.sarif' - ) - x64 = @( - 'fuzz-pickle.sarif', - 'fuzz-renpy.sarif', - 'fuzz-rpgmaker.sarif', - 'fuzz-zanzarah.sarif', - 'leak-probe.sarif', - 'renpy.sarif', - 'rpgmaker.sarif', - 'tests.sarif', - 'zanzarah.sarif' - ) - ARM64 = @( - 'fuzz-pickle.sarif', - 'fuzz-renpy.sarif', - 'fuzz-rpgmaker.sarif', - 'fuzz-zanzarah.sarif', - 'renpy.sarif', - 'rpgmaker.sarif', - 'tests.sarif', - 'zanzarah.sarif' - ) -} -foreach ($platform in @('Win32', 'x64', 'ARM64')) { - $actualReports = @( - $analysisProjects | - Where-Object { $platform -in @($_.AnalysisPlatforms -split ';') } | - ForEach-Object { "$($_.AnalysisReportName).sarif" } | - Sort-Object - ) - Assert-SequenceEqual ` - -Actual $actualReports ` - -Expected $expectedReportsByPlatform[$platform] ` - -Description "$platform compiler-analysis report manifest" -} - -$analysisTarget = $aggregateProject.SelectSingleNode('//msb:Target[@Name="RunCompilerAnalysis"]', $namespace) -if ($null -eq $analysisTarget) { - throw 'The aggregate project must expose a RunCompilerAnalysis target.' -} -$activeProject = $analysisTarget.SelectSingleNode('msb:ItemGroup/msb:ActiveAnalysisProject', $namespace) -if ( - $null -eq $activeProject -or - $activeProject.Include -ne '@(AnalysisProject)' -or - $activeProject.Condition -notmatch 'AnalysisPlatforms' -or - $activeProject.Condition -notmatch '\$\(Platform\)' -) { - throw 'RunCompilerAnalysis must filter the explicit manifest by MSBuild platform.' -} -$analysisBuild = $analysisTarget.SelectSingleNode('msb:MSBuild', $namespace) -$expectedProperties = '$(ProjectProperties);Configuration=%(ActiveAnalysisProject.AnalysisConfiguration);ObserverAnalysisReportName=%(ActiveAnalysisProject.AnalysisReportName);ObserverCompileAnalysis=true;ForceRebuild=true' -if ( - $null -eq $analysisBuild -or - $analysisBuild.Projects -ne '@(ActiveAnalysisProject)' -or - $analysisBuild.Targets -ne 'ClCompile' -or - $analysisBuild.BuildInParallel -ne 'true' -or - $analysisBuild.GetAttribute('ContinueOnError') -ne 'ErrorAndContinue' -or - $analysisBuild.Properties -ne $expectedProperties -) { - throw 'RunCompilerAnalysis must analyze every project, retain failures, and avoid linking.' -} - -$expectedPlatformMonikers = @( - "'`$(Platform)' == 'Win32'|x86", - "'`$(Platform)' == 'x64'|x64", - "'`$(Platform)' == 'ARM64'|arm64" -) -$actualPlatformMonikers = @( - $aggregateProject.SelectNodes('//msb:AnalysisPlatformMoniker', $namespace) | - ForEach-Object { "$($_.Condition)|$($_.InnerText)" } -) -Assert-SequenceEqual ` - -Actual $actualPlatformMonikers ` - -Expected $expectedPlatformMonikers ` - -Description 'Compiler-analysis report platform mapping' -$expectedReportProperties = @( - 'AnalysisExpectedReportRoot|$([System.IO.Path]::GetFullPath(''$(MSBuildThisFileDirectory)..\.artifacts\reports\msvc\''))', - 'AnalysisRequestedReportRoot|$([System.IO.Path]::GetFullPath(''$(ObserverAnalysisReportDirectory)\''))', - 'AnalysisReportArchDirectory|$([System.IO.Path]::GetFullPath(''$(AnalysisRequestedReportRoot)$(AnalysisPlatformMoniker)\''))' -) -$actualReportProperties = @( - foreach ($propertyName in @('AnalysisExpectedReportRoot', 'AnalysisRequestedReportRoot', 'AnalysisReportArchDirectory')) { - $property = $aggregateProject.SelectSingleNode("//msb:$propertyName", $namespace) - if ($null -eq $property) { - "${propertyName}|" - } else { - "${propertyName}|$($property.InnerText)" - } - } -) -Assert-SequenceEqual ` - -Actual $actualReportProperties ` - -Expected $expectedReportProperties ` - -Description 'Compiler-analysis report path contract' -$pathValidationError = $analysisTarget.SelectSingleNode('msb:Error[contains(@Condition, "AnalysisExpectedReportRoot")]', $namespace) -$reparseValidationError = $analysisTarget.SelectSingleNode('msb:Error[contains(@Condition, "ReparsePoint")]', $namespace) -$validatedPaths = @( - $analysisTarget.SelectNodes('msb:ItemGroup/msb:AnalysisReportPathToValidate[@Include]', $namespace) | - ForEach-Object Include -) -Assert-SequenceEqual ` - -Actual $validatedPaths ` - -Expected @( - '$(MSBuildThisFileDirectory)..\.artifacts', - '$(MSBuildThisFileDirectory)..\.artifacts\reports', - '$(AnalysisExpectedReportRoot)', - '$(AnalysisReportArchDirectory)' - ) ` - -Description 'Compiler-analysis report paths validated before cleanup' -if ( - $null -eq $pathValidationError -or - $null -eq $reparseValidationError -) { - throw 'RunCompilerAnalysis must reject an unexpected report root and reparse-point cleanup paths.' -} -$pathAttributesUpdate = $analysisTarget.SelectSingleNode( - 'msb:ItemGroup/msb:AnalysisReportPathToValidate[@Update="@(AnalysisReportPathToValidate)"]/msb:PathAttributes', - $namespace -) -if ( - $null -eq $pathAttributesUpdate -or - $pathAttributesUpdate.InnerText -ne "$([char]36)([System.IO.File]::GetAttributes('%(AnalysisReportPathToValidate.FullPath)'))" -) { - throw 'Reparse validation must populate path attributes only after every path item has a full identity.' -} -$staleReports = $analysisTarget.SelectSingleNode('msb:ItemGroup/msb:ExistingAnalysisReport', $namespace) -$deleteReports = $analysisTarget.SelectSingleNode('msb:Delete', $namespace) -if ( - $null -eq $staleReports -or - $staleReports.Include -ne '$(AnalysisReportArchDirectory)*.sarif' -or - $null -eq $deleteReports -or - $deleteReports.Files -ne '@(ExistingAnalysisReport)' -) { - throw 'RunCompilerAnalysis must remove stale architecture SARIF before producing the exact manifest.' -} - -$rebuildTarget = $aggregateProject.SelectSingleNode('//msb:Target[@Name="Rebuild"]', $namespace) -$analysisDispatch = $rebuildTarget.SelectSingleNode('msb:CallTarget[@Targets="RunCompilerAnalysis"]', $namespace) -if ($null -eq $analysisDispatch -or $analysisDispatch.Condition -ne "'`$(ObserverRunCodeAnalysis)' == 'true'") { - throw 'Aggregate Rebuild must dispatch the compiler-analysis graph only when explicitly requested.' -} -$binaryRebuild = $rebuildTarget.SelectSingleNode('msb:MSBuild', $namespace) -if ($null -eq $binaryRebuild -or $binaryRebuild.Condition -ne "'`$(ObserverRunCodeAnalysis)' != 'true'") { - throw 'Ordinary aggregate Rebuild must retain the module/test binary graph.' -} - -$projectProperties = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverProject.props') -$reportNameDefault = $projectProperties.SelectSingleNode('//msb:ObserverAnalysisReportName', $namespace) -if ( - $null -eq $reportNameDefault -or - $reportNameDefault.InnerText -ne '$(ProjectName)' -or - $reportNameDefault.Condition -ne "'`$(ObserverAnalysisReportName)' == ''" -) { - throw 'Analysis reports must default to the unique MSBuild project name.' -} -$prefastLog = $projectProperties.SelectSingleNode('//msb:ClCompile/msb:PREfastLog', $namespace) -if ( - $null -eq $prefastLog -or - $prefastLog.InnerText -ne '$(ObserverAnalysisReportDirectory)\$(PlatformMoniker)\$(ObserverAnalysisReportName).sarif' -) { - throw 'PREfastLog must use the explicit per-project report name.' -} - -$fuzzProperties = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverFuzz.props') -$fuzzValidation = $fuzzProperties.SelectSingleNode('//msb:Target[@Name="ValidateFuzzConfiguration"]/msb:Error', $namespace) -$expectedFuzzCondition = "'`$(ObserverCompileAnalysis)' != 'true' And '`$(Configuration)|`$(Platform)' != 'Fuzz|x64'" -if ($null -eq $fuzzValidation -or $fuzzValidation.Condition -ne $expectedFuzzCondition) { - throw 'Fuzz validation may be bypassed only by the compile-only analysis graph.' -} - -$leakProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\projects\leak-probe.vcxproj') -$leakValidation = $leakProject.SelectSingleNode( - '//msb:Target[@Name="ValidateLeakProbeConfiguration"]/msb:Error', - $namespace -) -if ($null -eq $leakValidation -or $leakValidation.Condition -ne "'`$(Configuration)|`$(Platform)' != 'Release|x64'") { - throw 'Compile analysis must not weaken the leak probe Release|x64 runtime contract.' -} - -Write-Host '[OK] MSVC compile-analysis graph and SARIF manifest cover every first-party target.' diff --git a/build/tests/dynamic-graph-leaves.Tests.ps1 b/build/tests/dynamic-graph-leaves.Tests.ps1 deleted file mode 100644 index 949a62b..0000000 --- a/build/tests/dynamic-graph-leaves.Tests.ps1 +++ /dev/null @@ -1,193 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$leafLibrary = Join-Path $repositoryRoot 'build\lib\dynamic-graph-leaves.ps1' -$probeSource = Join-Path $repositoryRoot 'src\tests\leaks\probe.cpp' - -if (-not (Test-Path -LiteralPath $leafLibrary -PathType Leaf)) { - throw "Dynamic graph leaf library is missing: $leafLibrary" -} -. $leafLibrary - -function Assert-Equal { - param( - [Parameter(Mandatory)] $Actual, - [Parameter(Mandatory)] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if ($Actual -cne $Expected) { - throw "$Description expected '$Expected', received '$Actual'." - } -} - -function Assert-Throw { - param( - [Parameter(Mandatory)][scriptblock] $Action, - [Parameter(Mandatory)][string] $Description - ) - - try { - & $Action - } - catch { - return - } - throw "Expected failure: $Description" -} - -$targetSpecs = @(Get-DynamicFuzzTargetSpec) -Assert-Equal -Actual $targetSpecs.Count -Expected 4 -Description 'Fuzz target count' -Assert-Equal -Actual (($targetSpecs.Name | Sort-Object) -join ',') -Expected 'pickle,renpy,rpgmaker,zanzarah' -Description 'Fuzz target names' -Assert-Equal -Actual ($targetSpecs | Where-Object Name -eq 'pickle').MaxLength -Expected 262144 -Description 'Pickle maximum input length' -foreach ($spec in @($targetSpecs | Where-Object Name -ne 'pickle')) { - Assert-Equal -Actual $spec.MaxLength -Expected 1048576 -Description "$($spec.Name) maximum input length" -} -Assert-DynamicRunId -RunId 'local-2026.08.01_01' -Assert-Throw -Description 'unsafe dynamic run id' -Action { - Assert-DynamicRunId -RunId '..\escape' -} - -$replayArguments = Get-DynamicFuzzArgumentList ` - -Phase seed-replay ` - -TargetName pickle ` - -InputPath @('C:\seeds\one', 'C:\seeds\two') ` - -ArtifactDirectory 'C:\artifacts\replay' -Assert-Equal -Actual $replayArguments[0] -Expected 'C:\seeds\one' -Description 'Replay first seed' -Assert-Equal -Actual $replayArguments[1] -Expected 'C:\seeds\two' -Description 'Replay second seed' -if ($replayArguments -notcontains '-max_len=262144' -or $replayArguments -match '^-max_total_time=') { - throw 'Replay arguments do not use libFuzzer individual-file mode with the target maximum length.' -} - -$timedArguments = Get-DynamicFuzzArgumentList ` - -Phase timed ` - -TargetName renpy ` - -InputPath @('C:\corpus\renpy') ` - -ArtifactDirectory 'C:\artifacts\timed' ` - -Seconds 17 -if ($timedArguments -notcontains '-max_total_time=17' -or $timedArguments -notcontains '-use_value_profile=1') { - throw 'Timed fuzz arguments do not enforce the requested duration and value profile.' -} -Assert-Throw -Description 'timed fuzz without a duration' -Action { - Get-DynamicFuzzArgumentList -Phase timed -TargetName renpy -InputPath @('C:\corpus') -ArtifactDirectory 'C:\out' -} - -$temporaryRoot = Join-Path $repositoryRoot ".artifacts\dynamic-leaf-contract-$([guid]::NewGuid().ToString('N'))" -$seedRoot = Join-Path $temporaryRoot 'seeds' -$corpusRoot = Join-Path $temporaryRoot 'corpus' -New-Item -ItemType Directory -Path $seedRoot -Force | Out-Null -[System.IO.File]::WriteAllText((Join-Path $seedRoot 'hex-seed.hex'), '00 7f FF') -[System.IO.File]::WriteAllBytes((Join-Path $seedRoot 'raw.seed'), [byte[]]@(1, 2, 3)) -try { - [void](Initialize-DynamicFuzzCorpus -SeedDirectory $seedRoot -CorpusDirectory $corpusRoot) - Assert-Equal -Actual ([Convert]::ToHexString([System.IO.File]::ReadAllBytes((Join-Path $corpusRoot 'hex-seed')))) -Expected '007FFF' -Description 'Decoded hex seed' - Assert-Equal -Actual ([Convert]::ToHexString([System.IO.File]::ReadAllBytes((Join-Path $corpusRoot 'raw.seed')))) -Expected '010203' -Description 'Copied raw seed' - [System.IO.File]::WriteAllText((Join-Path $seedRoot 'invalid.hex'), 'ABC') - Assert-Throw -Description 'odd-length hexadecimal seed' -Action { - Initialize-DynamicFuzzCorpus -SeedDirectory $seedRoot -CorpusDirectory (Join-Path $temporaryRoot 'invalid') - } -} -finally { - if (Test-Path -LiteralPath $temporaryRoot) { - Remove-Item -LiteralPath $temporaryRoot -Recurse -Force - } -} - -$leafRoot = Join-Path $repositoryRoot ".artifacts\dynamic-leaf-invoke-$([guid]::NewGuid().ToString('N'))" -$leafSeeds = Join-Path $leafRoot 'seeds' -$fakeFuzzer = Join-Path $leafRoot 'fuzz-pickle.exe' -$fakeProbe = Join-Path $leafRoot 'leak-probe.exe' -New-Item -ItemType Directory -Path $leafSeeds -Force | Out-Null -[System.IO.File]::WriteAllBytes((Join-Path $leafSeeds 'none.pickle'), [byte[]]@([char]'N', [char]'.')) -[System.IO.File]::WriteAllText($fakeFuzzer, 'fake') -[System.IO.File]::WriteAllText($fakeProbe, 'fake') -try { - $fuzzInvocations = [System.Collections.Generic.List[object]]::new() - $fuzzInvoker = { - param($FilePath, $Arguments, $WorkingDirectory, $LogPath) - $fuzzInvocations.Add([pscustomobject]@{ - FilePath = $FilePath - Arguments = @($Arguments) - WorkingDirectory = $WorkingDirectory - LogPath = $LogPath - }) - return 0 - }.GetNewClosure() - $fuzzEvidence = Invoke-DynamicFuzzLeaf ` - -Phase seed-replay ` - -TargetName pickle ` - -FuzzerPath $fakeFuzzer ` - -SeedDirectory $leafSeeds ` - -CorpusDirectory (Join-Path $leafRoot 'persistent-corpus') ` - -RunDirectory (Join-Path $leafRoot 'replay-run') ` - -WorkingDirectory $leafRoot ` - -Seconds 9 ` - -NativeInvoker $fuzzInvoker - Assert-Equal -Actual $fuzzInvocations.Count -Expected 1 -Description 'Fuzz leaf native invocation count' - Assert-Equal -Actual $fuzzEvidence.phase -Expected 'seed-replay' -Description 'Fuzz leaf evidence phase' - if ($fuzzInvocations[0].Arguments -notcontains '-max_len=262144') { - throw 'Fuzz leaf did not pass the selected target bound to its native adapter.' - } - if (-not (Test-Path -LiteralPath (Join-Path $leafRoot 'replay-run\result.json') -PathType Leaf)) { - throw 'Fuzz leaf did not write its isolated evidence file.' - } - - $probeCapture = { - param($FilePath, $Arguments, $WorkingDirectory) - $null = $FilePath, $WorkingDirectory - if ($Arguments -notcontains '--scenario' -or $Arguments -notcontains 'malformed') { - throw 'Leak preflight adapter did not receive the exact scenario.' - } - return @( - 'OBSERVER_LEAK_PROBE|READY|pid=77|mode=lifecycle|configuration=Release|scenarios=malformed', - 'OBSERVER_LEAK_PROBE|SNAPSHOT|baseline|pid=77|completed_operations=1', - 'OBSERVER_LEAK_PROBE|DONE|pid=77|completed_operations=4' - ) - } - $leakEvidencePath = Join-Path $leafRoot 'leak-preflight.json' - $leakEvidence = Invoke-DynamicLeakPreflightLeaf ` - -ProbePath $fakeProbe ` - -BinaryDirectory $leafRoot ` - -Mode lifecycle ` - -Scenario malformed ` - -EvidencePath $leakEvidencePath ` - -NativeCapture $probeCapture - Assert-Equal -Actual $leakEvidence.processId -Expected 77 -Description 'Leak preflight evidence process ID' - if (-not (Test-Path -LiteralPath $leakEvidencePath -PathType Leaf)) { - throw 'Leak preflight did not write its isolated evidence file.' - } -} -finally { - if (Test-Path -LiteralPath $leafRoot) { - Remove-Item -LiteralPath $leafRoot -Recurse -Force - } -} - -$scenarioNames = @(Get-DynamicLeakScenarioName) -Assert-Equal -Actual ($scenarioNames -join ',') -Expected 'small-success,malformed,cancellation,read-failure,write-failure,large-metadata,sparse-metadata' -Description 'Leak scenarios' -$probeArguments = Get-DynamicLeakProbeArgumentList ` - -Mode lifecycle ` - -Scenario sparse-metadata ` - -Warmup 2 ` - -Iterations 3 ` - -Windows 4 -Assert-Equal -Actual ($probeArguments -join ' ') -Expected '--mode lifecycle --scenario sparse-metadata --warmup 2 --iterations 3 --windows 4' -Description 'Leak probe arguments' - -$ready = 'OBSERVER_LEAK_PROBE|READY|pid=42|mode=operations|configuration=Release|scenarios=read-failure' -$readyEvidence = Assert-DynamicLeakReadyMarker -Line $ready -Mode operations -Scenario read-failure -Assert-Equal -Actual $readyEvidence.ProcessId -Expected 42 -Description 'Leak READY process ID' -Assert-Throw -Description 'READY marker for another scenario' -Action { - Assert-DynamicLeakReadyMarker -Line $ready -Mode operations -Scenario malformed -} - -$probeText = Get-Content -Raw -LiteralPath $probeSource -foreach ($requiredProbeContract in @('--scenario', 'settings.scenario', 'selected_scenario')) { - if (-not $probeText.Contains($requiredProbeContract, [System.StringComparison]::Ordinal)) { - throw "Leak probe source is missing exact scenario selection contract: $requiredProbeContract" - } -} - -Write-Output '[OK] Dynamic graph leaf contracts passed.' diff --git a/build/tests/graph-leaves.Tests.ps1 b/build/tests/graph-leaves.Tests.ps1 deleted file mode 100644 index 57a7cca..0000000 --- a/build/tests/graph-leaves.Tests.ps1 +++ /dev/null @@ -1,463 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -. (Join-Path $repositoryRoot 'build\lib\analysis-reporting.ps1') -. (Join-Path $repositoryRoot 'build\lib\graph-leaves.ps1') - -function Assert-Equal { - param( - [Parameter(Mandatory)][AllowNull()] $Actual, - [Parameter(Mandatory)][AllowNull()] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if (-not [object]::Equals($Actual, $Expected)) { - throw "${Description}: actual='$Actual', expected='$Expected'." - } -} - -function Assert-ScriptFailure { - param( - [Parameter(Mandatory)][scriptblock] $Action, - [Parameter(Mandatory)][string] $Pattern, - [Parameter(Mandatory)][string] $Description - ) - - try { - & $Action - } catch { - if ($_.Exception.Message -notmatch $Pattern) { - throw "${Description}: unexpected error '$($_.Exception.Message)'." - } - return - } - throw "${Description}: expected an error matching '$Pattern'." -} - -$buildRequest = Get-GraphBuildProjectRequest ` - -RepositoryRoot $repositoryRoot ` - -Architecture 'x64' ` - -Configuration 'Debug' ` - -Project 'renpy' -Assert-Equal -Actual $buildRequest.Target -Expected 'Build' -Description 'Native project target' -Assert-Equal -Actual $buildRequest.Platform -Expected 'x64' -Description 'Native project platform' -Assert-Equal ` - -Actual $buildRequest.ProjectPath ` - -Expected (Join-Path $repositoryRoot 'build\projects\renpy.vcxproj') ` - -Description 'Allowlisted native project path' - -Assert-ScriptFailure ` - -Action { Get-GraphBuildProjectRequest -RepositoryRoot $repositoryRoot -Architecture x64 -Configuration Debug -Project '..\renpy' } ` - -Pattern 'not allowlisted' ` - -Description 'Project traversal rejection' - -$selectedSource = 'src/core/io/bounded_stream.cpp' -$unitSlug = Get-GraphTranslationUnitSlug -Source $selectedSource -Assert-Equal -Actual $unitSlug -Expected 'core-io-bounded-stream-bc188848' -Description 'Stable TU slug' - -$tidyRequest = Get-GraphAnalysisRequest ` - -RepositoryRoot $repositoryRoot ` - -Architecture 'x64' ` - -Backend 'clang-tidy' ` - -Project 'renpy' ` - -SelectedFile $selectedSource ` - -Unit $unitSlug -Assert-Equal -Actual $tidyRequest.Target -Expected 'ClCompile' -Description 'Selected-file target' -Assert-Equal -Actual $tidyRequest.Configuration -Expected 'Debug' -Description 'Selected-file configuration' -Assert-Equal ` - -Actual $tidyRequest.Properties.SelectedFiles ` - -Expected (Join-Path $repositoryRoot 'src\core\io\bounded_stream.cpp') ` - -Description 'SelectedFiles exact MSBuild seam' -Assert-Equal -Actual $tidyRequest.Properties.SelectedFilesBuildPCH -Expected 'false' -Description 'Selected-file PCH isolation' -Assert-Equal -Actual $tidyRequest.Properties.SelectedFilesBuildModules -Expected 'false' -Description 'Selected-file module isolation' -Assert-Equal -Actual $tidyRequest.Properties.EnableMicrosoftCodeAnalysis -Expected 'false' -Description 'Tidy excludes PREfast' -Assert-Equal -Actual $tidyRequest.Properties.ObserverEnableClangTidy -Expected 'true' -Description 'Tidy enabled' -Assert-Equal ` - -Actual $tidyRequest.Properties.IntDir ` - -Expected (Join-Path $repositoryRoot ".artifacts\analysis\clang-tidy\x64\renpy\$unitSlug\obj\") ` - -Description 'Tidy isolated IntDir' - -Assert-ScriptFailure ` - -Action { - Get-GraphAnalysisRequest ` - -RepositoryRoot $repositoryRoot ` - -Architecture x64 ` - -Backend clang-tidy ` - -Project renpy ` - -SelectedFile src/tests/main.cpp ` - -Unit tests-main - } ` - -Pattern 'not a ClCompile Include item' ` - -Description 'Cross-project selected file rejection' - -$msvcSelectedSource = 'src/modules/renpy/pickle.cpp' -$msvcUnitSlug = Get-GraphTranslationUnitSlug -Source $msvcSelectedSource -$msvcRequest = Get-GraphAnalysisRequest ` - -RepositoryRoot $repositoryRoot ` - -Architecture 'x64' ` - -Backend 'msvc' ` - -Project 'renpy' ` - -SelectedFile $msvcSelectedSource ` - -Unit $msvcUnitSlug -Assert-Equal -Actual $msvcRequest.Configuration -Expected 'Debug' -Description 'MSVC unit analysis configuration' -Assert-Equal -Actual $msvcRequest.Properties.EnableMicrosoftCodeAnalysis -Expected 'true' -Description 'PREfast enabled' -Assert-Equal -Actual $msvcRequest.Properties.ObserverEnableClangTidy -Expected 'false' -Description 'PREfast excludes tidy' -Assert-Equal ` - -Actual $msvcRequest.Properties.SelectedFiles ` - -Expected (Join-Path $repositoryRoot 'src\modules\renpy\pickle.cpp') ` - -Description 'PREfast SelectedFiles exact seam' -Assert-Equal ` - -Actual $msvcRequest.Properties.IntDir ` - -Expected (Join-Path $repositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\obj\") ` - -Description 'PREfast isolated IntDir' -Assert-Equal ` - -Actual $msvcRequest.Properties.ObserverAnalysisReportPath ` - -Expected (Join-Path $repositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\renpy.sarif") ` - -Description 'PREfast isolated raw report' - -$leakSelectedSource = 'src/tests/leaks/probe.cpp' -$leakUnitSlug = Get-GraphTranslationUnitSlug -Source $leakSelectedSource -$leakMsvcRequest = Get-GraphAnalysisRequest ` - -RepositoryRoot $repositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -Project leak-probe ` - -SelectedFile $leakSelectedSource ` - -Unit $leakUnitSlug -Assert-Equal -Actual $leakMsvcRequest.Configuration -Expected 'Release' -Description 'Leak unit analysis configuration' - -Assert-ScriptFailure ` - -Action { Get-GraphAnalysisRequest -RepositoryRoot $repositoryRoot -Architecture x64 -Backend msvc -Project renpy } ` - -Pattern 'requires SelectedFile and Unit' ` - -Description 'Project-wide PREfast rejection' - -[xml] $projectProperties = Get-Content -Raw -LiteralPath (Join-Path $repositoryRoot 'build\ObserverProject.props') -$namespace = [System.Xml.XmlNamespaceManager]::new($projectProperties.NameTable) -$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') -$isolatedPrefastLog = $projectProperties.SelectSingleNode( - '//msb:ClCompile/msb:PREfastLog[contains(@Condition, "ObserverAnalysisReportPath")]', - $namespace -) -if ($null -eq $isolatedPrefastLog -or $isolatedPrefastLog.InnerText -ne '$(ObserverAnalysisReportPath)') { - throw 'ObserverProject.props must honor the isolated per-analysis-node PREfast path.' -} - -$testRoot = Join-Path $repositoryRoot ".artifacts\contract-tests\graph-leaves-$([guid]::NewGuid().ToString('N'))" -try { - $artifactJunction = Join-Path $testRoot 'artifact-junction' - New-Item -ItemType Directory -Force -Path $testRoot | Out-Null - New-Item -ItemType Junction -Path $artifactJunction -Target (Join-Path $repositoryRoot 'src') | Out-Null - try { - Assert-ScriptFailure ` - -Action { Resolve-GraphArtifactPath -RepositoryRoot $repositoryRoot -Path $artifactJunction } ` - -Pattern 'reparse point' ` - -Description 'Artifact leaf reparse-point rejection' - } finally { - $junctionItem = Get-Item -Force -LiteralPath $artifactJunction - if (($junctionItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { - throw "Refusing to remove a non-reparse test path: $artifactJunction" - } - Remove-Item -Force -LiteralPath $artifactJunction - } - - $sarifRepositoryRoot = Join-Path $testRoot 'repository' - $projectRoot = Join-Path $sarifRepositoryRoot 'build\projects' - New-Item -ItemType Directory -Force -Path $projectRoot | Out-Null - @' - - - - - - -'@ | Set-Content -LiteralPath (Join-Path $projectRoot 'renpy.vcxproj') -Encoding utf8 - @' - - - - - -'@ | Set-Content -LiteralPath (Join-Path $projectRoot 'rpgmaker.vcxproj') -Encoding utf8 - - $msvcUnitRoot = Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x64\units' - New-Item -ItemType Directory -Force -Path $msvcUnitRoot | Out-Null - $rpgmakerUnitSlug = Get-GraphTranslationUnitSlug -Source 'src/modules/rpgmaker/rpgmaker.cpp' - Assert-ScriptFailure ` - -Action { - Assert-GraphProjectUnit ` - -RepositoryRoot $sarifRepositoryRoot ` - -Project renpy ` - -Unit $unitSlug.ToUpperInvariant() - } ` - -Pattern 'not a ClCompile unit' ` - -Description 'Translation-unit slug case rejection' - $convertedMsvcReports = [System.Collections.Generic.List[string]]::new() - foreach ($msvcCase in @( - [pscustomobject]@{ Project = 'renpy'; Unit = $msvcUnitSlug }, - [pscustomobject]@{ Project = 'renpy'; Unit = 'core-io-bounded-stream-bc188848' } - )) { - $rawMsvc = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\$($msvcCase.Project)\$($msvcCase.Unit)\$($msvcCase.Project).sarif" - New-Item -ItemType Directory -Force -Path (Split-Path $rawMsvc -Parent) | Out-Null - [ordered]@{ - version = '2.1.0' - runs = @( - [ordered]@{ - tool = [ordered]@{ driver = [ordered]@{ name = 'PREfast' } } - results = @() - } - ) - } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $rawMsvc -Encoding utf8 - $convertedMsvc = Join-Path $msvcUnitRoot "$($msvcCase.Project)-$($msvcCase.Unit).sarif" - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $rawMsvc ` - -OutputPath $convertedMsvc ` - -Architecture x64 ` - -Project $msvcCase.Project ` - -Unit $msvcCase.Unit - $convertedMsvcReports.Add($convertedMsvc) - } - $convertedMsvcMerged = Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x64\msvc-analyze.sarif' - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths $convertedMsvcReports.ToArray() ` - -OutputPath $convertedMsvcMerged - $convertedMsvcResult = Get-Content -Raw -LiteralPath $convertedMsvcMerged | ConvertFrom-Json - Assert-Equal -Actual @($convertedMsvcResult.runs).Count -Expected 2 -Description 'MSVC unit SARIF identities are mergeable' - - $tidyUnitRoot = Join-Path $sarifRepositoryRoot '.artifacts\reports\clang-tidy\x64\units' - New-Item -ItemType Directory -Force -Path $tidyUnitRoot | Out-Null - $tidyReports = [System.Collections.Generic.List[string]]::new() - foreach ($tidyCase in @( - [pscustomobject]@{ Project = 'renpy'; Unit = 'core-io-bounded-stream-bc188848' }, - [pscustomobject]@{ Project = 'rpgmaker'; Unit = $rpgmakerUnitSlug } - )) { - $tidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\$($tidyCase.Project)\$($tidyCase.Unit)\obj" - New-Item -ItemType Directory -Force -Path $tidyObjectRoot | Out-Null - New-Item -ItemType File -Force -Path (Join-Path $tidyObjectRoot "$($tidyCase.Project).ClangTidy.log") | Out-Null - $tidyReport = Join-Path $tidyUnitRoot "$($tidyCase.Project)-$($tidyCase.Unit).sarif" - Convert-GraphClangTidyUnitSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -ObjectRoot $tidyObjectRoot ` - -OutputPath $tidyReport ` - -Architecture x64 ` - -Project $tidyCase.Project ` - -Unit $tidyCase.Unit - $tidyReports.Add($tidyReport) - } - $tidyMergedPath = Join-Path $sarifRepositoryRoot '.artifacts\reports\clang-tidy\x64\clang-tidy.sarif' - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend clang-tidy ` - -InputPaths $tidyReports.ToArray() ` - -OutputPath $tidyMergedPath - $tidyMerged = Get-Content -Raw -LiteralPath $tidyMergedPath | ConvertFrom-Json - Assert-Equal -Actual @($tidyMerged.runs).Count -Expected 2 -Description 'Tidy unit SARIF identities are mergeable' - if ($tidyMerged.runs[0].automationDetails.id -eq $tidyMerged.runs[1].automationDetails.id) { - throw 'Tidy unit SARIF identities must be unique.' - } - - $foreignUnit = $rpgmakerUnitSlug - $foreignRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$foreignUnit\renpy.sarif" - New-Item -ItemType Directory -Force -Path (Split-Path $foreignRaw -Parent) | Out-Null - Copy-Item -LiteralPath $convertedMsvcReports[0] -Destination $foreignRaw - Assert-ScriptFailure ` - -Action { - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $foreignRaw ` - -OutputPath (Join-Path $msvcUnitRoot "renpy-$foreignUnit.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $foreignUnit - } ` - -Pattern 'not a ClCompile unit' ` - -Description 'Regex-shaped foreign unit rejection' - - $wrongVersionRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$msvcUnitSlug\renpy.sarif" - New-Item -ItemType Directory -Force -Path (Split-Path $wrongVersionRaw -Parent) | Out-Null - [ordered]@{ version = '2.0.0'; runs = @([ordered]@{}) } | - ConvertTo-Json -Depth 8 | - Set-Content -LiteralPath $wrongVersionRaw -Encoding utf8 - Assert-ScriptFailure ` - -Action { - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $wrongVersionRaw ` - -OutputPath (Join-Path $msvcUnitRoot "renpy-$msvcUnitSlug.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $msvcUnitSlug - } ` - -Pattern 'version.*2\.1\.0' ` - -Description 'Normalized SARIF version rejection' - - $emptyRunsRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x64\renpy\$unitSlug\renpy.sarif" - New-Item -ItemType Directory -Force -Path (Split-Path $emptyRunsRaw -Parent) | Out-Null - [ordered]@{ version = '2.1.0'; runs = @() } | - ConvertTo-Json -Depth 8 | - Set-Content -LiteralPath $emptyRunsRaw -Encoding utf8 - Assert-ScriptFailure ` - -Action { - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $emptyRunsRaw ` - -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $unitSlug - } ` - -Pattern 'runs.*non-empty list of objects' ` - -Description 'Normalized SARIF empty runs rejection' - - [ordered]@{ version = '2.1.0'; runs = @('not-an-object') } | - ConvertTo-Json -Depth 8 | - Set-Content -LiteralPath $emptyRunsRaw -Encoding utf8 - Assert-ScriptFailure ` - -Action { - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $emptyRunsRaw ` - -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $unitSlug - } ` - -Pattern 'runs.*non-empty list of objects' ` - -Description 'Normalized SARIF non-object run rejection' - - $crossRootRaw = Join-Path $sarifRepositoryRoot ".artifacts\analysis\msvc\x86\renpy\$unitSlug\renpy.sarif" - New-Item -ItemType Directory -Force -Path (Split-Path $crossRootRaw -Parent) | Out-Null - Copy-Item -LiteralPath $convertedMsvcReports[0] -Destination $crossRootRaw - Assert-ScriptFailure ` - -Action { - Convert-GraphMsvcSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -InputPath $crossRootRaw ` - -OutputPath (Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $unitSlug - } ` - -Pattern 'expected input path' ` - -Description 'MSVC cross-architecture input rejection' - - $wrongTidyOutput = Join-Path $sarifRepositoryRoot ".artifacts\reports\clang-tidy\x64\units\rpgmaker-$unitSlug.sarif" - $validTidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\renpy\$unitSlug\obj" - New-Item -ItemType Directory -Force -Path $validTidyObjectRoot | Out-Null - New-Item -ItemType File -Force -Path (Join-Path $validTidyObjectRoot 'renpy.ClangTidy.log') | Out-Null - Assert-ScriptFailure ` - -Action { - Convert-GraphClangTidyUnitSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -ObjectRoot $validTidyObjectRoot ` - -OutputPath $wrongTidyOutput ` - -Architecture x64 ` - -Project renpy ` - -Unit $unitSlug - } ` - -Pattern 'expected output path' ` - -Description 'Tidy project relabeling rejection' - - $wrongTidyObjectRoot = Join-Path $sarifRepositoryRoot ".artifacts\analysis\clang-tidy\x64\rpgmaker\$unitSlug\obj" - New-Item -ItemType Directory -Force -Path $wrongTidyObjectRoot | Out-Null - Assert-ScriptFailure ` - -Action { - Convert-GraphClangTidyUnitSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -ObjectRoot $wrongTidyObjectRoot ` - -OutputPath (Join-Path $tidyUnitRoot "renpy-$unitSlug.sarif") ` - -Architecture x64 ` - -Project renpy ` - -Unit $unitSlug - } ` - -Pattern 'expected object root path' ` - -Description 'Tidy object-root relabeling rejection' - - $emptyMergeInput = Join-Path $msvcUnitRoot "renpy-$unitSlug.sarif" - [ordered]@{ version = '2.1.0'; runs = @() } | - ConvertTo-Json -Depth 8 | - Set-Content -LiteralPath $emptyMergeInput -Encoding utf8 - Assert-ScriptFailure ` - -Action { - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths @($emptyMergeInput) ` - -OutputPath $convertedMsvcMerged - } ` - -Pattern 'runs.*non-empty list of objects' ` - -Description 'Zero-run merge input rejection' - - [ordered]@{ - version = '2.1.0' - runs = @([ordered]@{ automationDetails = [ordered]@{ id = "msvc-analyze/x64/rpgmaker/$foreignUnit/" } }) - } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $emptyMergeInput -Encoding utf8 - Assert-ScriptFailure ` - -Action { - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths @($emptyMergeInput) ` - -OutputPath $convertedMsvcMerged - } ` - -Pattern 'expected unit identities' ` - -Description 'Merge relabeled identity rejection' - - $crossBackendMergeInput = Join-Path $tidyUnitRoot "renpy-$unitSlug.sarif" - Copy-Item -LiteralPath $emptyMergeInput -Destination $crossBackendMergeInput -Force - Assert-ScriptFailure ` - -Action { - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths @($crossBackendMergeInput) ` - -OutputPath $convertedMsvcMerged - } ` - -Pattern 'expected input path' ` - -Description 'Merge cross-backend input rejection' - - Assert-ScriptFailure ` - -Action { - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths @($emptyMergeInput) ` - -OutputPath (Join-Path $sarifRepositoryRoot '.artifacts\reports\msvc\x86\msvc-analyze.sarif') - } ` - -Pattern 'expected output path' ` - -Description 'Merge cross-architecture output rejection' - - Assert-ScriptFailure ` - -Action { - Merge-GraphSarif ` - -RepositoryRoot $sarifRepositoryRoot ` - -Architecture x64 ` - -Backend msvc ` - -InputPaths @((Join-Path $msvcUnitRoot 'renpy-missing-12345678.sarif')) ` - -OutputPath $convertedMsvcMerged - } ` - -Pattern 'was not found' ` - -Description 'Missing unit SARIF rejection' -} finally { - if (Test-Path -LiteralPath $testRoot) { - $resolvedTestRoot = [System.IO.Path]::GetFullPath($testRoot) - $expectedParent = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts\contract-tests')) - if (-not $resolvedTestRoot.StartsWith($expectedParent + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to remove unexpected contract-test path: $resolvedTestRoot" - } - Remove-Item -Recurse -Force -LiteralPath $resolvedTestRoot - } -} - -Write-Output '[OK] Native graph leaves isolate selected-file analysis and deterministic SARIF fan-in.' diff --git a/build/tests/leak-release-contract.Tests.ps1 b/build/tests/leak-release-contract.Tests.ps1 deleted file mode 100644 index 4cb371c..0000000 --- a/build/tests/leak-release-contract.Tests.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$namespace = [System.Xml.XmlNamespaceManager]::new([System.Xml.NameTable]::new()) -$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') - -function Read-MSBuildProject { - param([Parameter(Mandatory)][string] $Path) - - [xml] $project = Get-Content -Raw -LiteralPath $Path - return $project -} - -function Assert-ContainsExactly { - param( - [Parameter(Mandatory)][string[]] $Actual, - [Parameter(Mandatory)][string[]] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - $difference = @(Compare-Object -ReferenceObject $Expected -DifferenceObject $Actual) - if ($difference.Count -ne 0) { - throw "$Description differs from the required Release contract." - } -} - -$probeProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\projects\leak-probe.vcxproj') -$runtimeLibraries = @( - $probeProject.SelectNodes('//msb:RuntimeLibrary', $namespace) | - ForEach-Object InnerText | - Sort-Object -Unique -) -Assert-ContainsExactly -Actual $runtimeLibraries -Expected @('MultiThreaded') -Description 'Leak probe runtime library' - -$dependencies = @( - $probeProject.SelectNodes('//msb:AdditionalDependencies', $namespace) | - ForEach-Object InnerText -) -if ($dependencies -notcontains 'zs.lib;%(AdditionalDependencies)') { - throw 'Leak probe must link the Release zlib library.' -} -if ($dependencies -match 'zsd\.lib') { - throw 'Leak probe must not link a Debug zlib library.' -} - -$validationError = $probeProject.SelectSingleNode( - '//msb:Target[@Name="ValidateLeakProbeConfiguration"]/msb:Error', - $namespace -) -if ($null -eq $validationError -or $validationError.Condition -notmatch 'Release\|x64') { - throw 'Leak probe validation must accept only Release|x64.' -} - -$aggregateProject = Read-MSBuildProject -Path (Join-Path $repositoryRoot 'build\ObserverModules.proj') -$aggregateValidation = $aggregateProject.SelectSingleNode( - '//msb:Target[@Name="BuildLeakProbe"]/msb:Error', - $namespace -) -if ($null -eq $aggregateValidation -or $aggregateValidation.Condition -notmatch 'Release\|x64') { - throw 'BuildLeakProbe must accept only Release|x64.' -} - -$orchestrationFiles = @( - Get-Item -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') - Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name -) -foreach ($requiredPattern in @( - "Invoke-MSBuild -Target 'BuildLeakProbe' -Architecture 'x64' -Configuration 'Release'", - "Get-BinaryDirectory -Architecture 'x64' -Configuration 'Release'", - "Assert-ReleaseBinary -Architecture 'x64'" - )) { - $matchingFiles = @( - $orchestrationFiles | - Where-Object { - (Get-Content -Raw -LiteralPath $_.FullName).Contains($requiredPattern, [StringComparison]::Ordinal) - } - ) - if ($matchingFiles.Count -ne 1) { - throw "Leak orchestration is missing the Release evidence step: $requiredPattern" - } -} - -Write-Host '[OK] Leak probe is constrained to the shipping x64 Release /MT configuration.' diff --git a/build/tests/package-manifest.Tests.ps1 b/build/tests/package-manifest.Tests.ps1 deleted file mode 100644 index cca25fa..0000000 --- a/build/tests/package-manifest.Tests.ps1 +++ /dev/null @@ -1,291 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$validatorPath = Join-Path $repositoryRoot 'build\lib\package-manifest.ps1' -if (-not (Test-Path -LiteralPath $validatorPath -PathType Leaf)) { - throw "Package manifest validator is missing: $validatorPath" -} -. $validatorPath - -$moduleEntries = @{ - renpy = @( - 'renpy.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/rpatool.txt' - 'docs/thirdparty/serde-pickle.txt' - 'docs/thirdparty/zlib.txt' - ) - rpgmaker = @( - 'rpgmaker.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/rgssad.txt' - ) - zanzarah = @( - 'zanzarah.so' - 'observer_user.ini' - 'docs/license.txt' - 'docs/thirdparty/Observer.txt' - 'docs/thirdparty/zanzapak.txt' - ) -} -$symbolsEntries = @('renpy.pdb', 'rpgmaker.pdb', 'zanzarah.pdb') -$assertionCount = 0 - -function Write-TestZip { - param( - [Parameter(Mandatory)][string] $Directory, - [Parameter(Mandatory)][string] $Name, - [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Entries - ) - - $archivePath = Join-Path $Directory $Name - $fileStream = [System.IO.File]::Open( - $archivePath, - [System.IO.FileMode]::CreateNew, - [System.IO.FileAccess]::ReadWrite, - [System.IO.FileShare]::None - ) - try { - $archive = [System.IO.Compression.ZipArchive]::new( - $fileStream, - [System.IO.Compression.ZipArchiveMode]::Create, - $true - ) - try { - foreach ($entryName in $Entries) { - $entry = $archive.CreateEntry($entryName, [System.IO.Compression.CompressionLevel]::NoCompression) - if ($entryName.EndsWith('/', [System.StringComparison]::Ordinal)) { - continue - } - - $entryStream = $entry.Open() - try { - $content = [System.Text.Encoding]::UTF8.GetBytes("fixture:$entryName") - $entryStream.Write($content, 0, $content.Length) - } - finally { - $entryStream.Dispose() - } - } - } - finally { - $archive.Dispose() - } - } - finally { - $fileStream.Dispose() - } - - return $archivePath -} - -function Assert-Success { - param( - [Parameter(Mandatory)][string] $Description, - [Parameter(Mandatory)][scriptblock] $Action - ) - - try { - & $Action | Out-Null - } - catch { - throw "$Description should succeed, but failed: $($_.Exception.Message)" - } - $script:assertionCount++ -} - -function Assert-Rejected { - param( - [Parameter(Mandatory)][string] $Description, - [Parameter(Mandatory)][string] $MessageFragment, - [Parameter(Mandatory)][scriptblock] $Action - ) - - $caught = $false - try { - & $Action | Out-Null - } - catch { - $caught = $true - if (-not $_.Exception.Message.Contains($MessageFragment, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "$Description failed for the wrong reason: $($_.Exception.Message)" - } - } - if (-not $caught) { - throw "$Description should have been rejected." - } - $script:assertionCount++ -} - -$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot '.artifacts')) -New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null -$temporaryRoot = [System.IO.Path]::GetFullPath( - (Join-Path $artifactsRoot "package-manifest-tests-$([guid]::NewGuid().ToString('N'))") -) -$requiredPrefix = $artifactsRoot.TrimEnd( - [System.IO.Path]::DirectorySeparatorChar, - [System.IO.Path]::AltDirectorySeparatorChar -) + [System.IO.Path]::DirectorySeparatorChar -if (-not $temporaryRoot.StartsWith($requiredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to create package test data outside .artifacts: $temporaryRoot" -} -New-Item -ItemType Directory -Path $temporaryRoot | Out-Null - -try { - foreach ($moduleName in @('renpy', 'rpgmaker', 'zanzarah')) { - $archivePath = Write-TestZip -Directory $temporaryRoot -Name "$moduleName-valid.zip" -Entries $moduleEntries[$moduleName] - Assert-Success "$moduleName exact manifest" { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName $moduleName - } - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-with-directory-entries.zip' -Entries @( - 'docs/' - 'docs/thirdparty/' - $moduleEntries.renpy - ) - Assert-Success 'Expected directory records' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-valid.zip' -Entries $symbolsEntries - Assert-Success 'Combined symbols exact manifest' { - Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-missing.zip' -Entries @( - $moduleEntries.renpy | Where-Object { $_ -ne 'renpy.so' } - ) - Assert-Rejected 'Missing module entry' 'does not match' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-extra.zip' -Entries @( - $moduleEntries.renpy - 'unexpected.txt' - ) - Assert-Rejected 'Extra module entry' 'does not match' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-extra-directory.zip' -Entries @( - $moduleEntries.renpy - 'unexpected/' - ) - Assert-Rejected 'Extra directory entry' 'does not match' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-missing.zip' -Entries @( - $symbolsEntries | Where-Object { $_ -ne 'zanzarah.pdb' } - ) - Assert-Rejected 'Missing symbol entry' 'does not match' { - Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-extra.zip' -Entries @( - $symbolsEntries - 'unexpected.pdb' - ) - Assert-Rejected 'Extra symbol entry' 'does not match' { - Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-extra-directory.zip' -Entries @( - $symbolsEntries - 'symbols/' - ) - Assert-Rejected 'Extra symbols directory entry' 'does not match' { - Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'symbols-empty.zip' -Entries @() - Assert-Rejected 'Empty symbols archive' 'does not match' { - Assert-ObserverSymbolsPackageManifest -ArchivePath $archivePath - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-duplicate.zip' -Entries @( - $moduleEntries.renpy - 'renpy.so' - ) - Assert-Rejected 'Duplicate ZIP entry' 'duplicate or case-colliding' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'renpy-case-collision.zip' -Entries @( - $moduleEntries.renpy - 'RenPy.so' - ) - Assert-Rejected 'Case-colliding ZIP entry' 'duplicate or case-colliding' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - - $unsafeNames = @( - '/absolute.txt' - 'C:/absolute.txt' - '../traversal.txt' - 'docs/../traversal.txt' - 'docs\backslash.txt' - 'docs//empty-segment.txt' - 'docs/./relative.txt' - 'docs/thirdparty/NUL.txt' - 'docs/thirdparty/bad:name.txt' - 'docs/thirdparty/trailing-dot.' - ) - foreach ($unsafeName in $unsafeNames) { - $archivePath = Write-TestZip -Directory $temporaryRoot -Name "unsafe-$([guid]::NewGuid().ToString('N')).zip" -Entries @( - $moduleEntries.renpy - $unsafeName - ) - Assert-Rejected "Unsafe ZIP entry '$unsafeName'" 'unsafe ZIP entry' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'renpy' - } - } - - $archivePath = Write-TestZip -Directory $temporaryRoot -Name 'rpgmaker-wrong-thirdparty.zip' -Entries @( - $moduleEntries.rpgmaker | Where-Object { $_ -ne 'docs/thirdparty/rgssad.txt' } - 'docs/thirdparty/zanzapak.txt' - ) - Assert-Rejected 'Third-party document from another module' 'does not match' { - Assert-ObserverModulePackageManifest -ArchivePath $archivePath -ModuleName 'rpgmaker' - } -} -finally { - $cleanupTarget = [System.IO.Path]::GetFullPath($temporaryRoot) - if (-not $cleanupTarget.Equals($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or - -not $cleanupTarget.StartsWith($requiredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing unsafe package test cleanup target: $cleanupTarget" - } - if (Test-Path -LiteralPath $cleanupTarget) { - $cleanupItem = Get-Item -Force -LiteralPath $cleanupTarget - if (-not $cleanupItem.PSIsContainer -or - ($cleanupItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing unsafe package test cleanup directory: $cleanupTarget" - } - - $cleanupFiles = @(Get-ChildItem -Force -LiteralPath $cleanupTarget) - foreach ($cleanupFile in $cleanupFiles) { - $expectedParent = [System.IO.Path]::GetDirectoryName($cleanupFile.FullName) - $isUnsafeFile = $cleanupFile.PSIsContainer -or - ($cleanupFile.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 -or - -not $cleanupFile.Extension.Equals('.zip', [System.StringComparison]::OrdinalIgnoreCase) -or - -not $expectedParent.Equals($cleanupTarget, [System.StringComparison]::OrdinalIgnoreCase) - if ($isUnsafeFile) { - throw "Refusing unexpected package test cleanup entry: $($cleanupFile.FullName)" - } - } - foreach ($cleanupFile in $cleanupFiles) { - Remove-Item -Force -LiteralPath $cleanupFile.FullName - } - Remove-Item -Force -LiteralPath $cleanupTarget - } -} - -Write-Host "[OK] Package manifest contract passed $assertionCount assertions." diff --git a/build/tests/package-smoke-contract.Tests.ps1 b/build/tests/package-smoke-contract.Tests.ps1 deleted file mode 100644 index ddb797b..0000000 --- a/build/tests/package-smoke-contract.Tests.ps1 +++ /dev/null @@ -1,154 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$artifactsRoot = Join-Path $repositoryRoot '.artifacts' -$manifestModule = Join-Path $repositoryRoot 'build\lib\package-manifest.ps1' -$commonModule = Join-Path $repositoryRoot 'build\lib\common.ps1' -$smokeModule = Join-Path $repositoryRoot 'build\lib\package-smoke.ps1' -$buildEntrypoint = Join-Path $repositoryRoot 'build\build.ps1' - -if (-not (Test-Path -LiteralPath $smokeModule -PathType Leaf)) { - throw "Package smoke module is missing: $smokeModule" -} - -. $manifestModule -$script:RepositoryRoot = $repositoryRoot -. $commonModule -. $smokeModule - -function Assert-Throw { - param( - [Parameter(Mandatory)][scriptblock] $Action, - [Parameter(Mandatory)][string] $Description - ) - - try { - & $Action - } - catch { - return - } - throw "Expected failure: $Description" -} - -$temporaryRoot = [System.IO.Path]::GetFullPath( - (Join-Path $artifactsRoot "package-smoke-contract-$([guid]::NewGuid().ToString('N'))") -) -$artifactsPrefix = [System.IO.Path]::GetFullPath($artifactsRoot) + [System.IO.Path]::DirectorySeparatorChar -if (-not $temporaryRoot.StartsWith($artifactsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to create package smoke test data outside .artifacts: $temporaryRoot" -} - -try { - $extractedRoot = Join-Path $temporaryRoot 'renpy' - foreach ($relativeName in $script:ObserverModulePackageEntries.renpy) { - $path = Join-Path $extractedRoot ($relativeName.Replace('/', '\')) - $parent = Split-Path $path -Parent - New-Item -ItemType Directory -Force -Path $parent | Out-Null - [System.IO.File]::WriteAllText($path, $relativeName) - } - - [void](Assert-ObserverExtractedModulePackage -DirectoryPath $extractedRoot -ModuleName 'renpy') - - $extraPath = Join-Path $extractedRoot 'unexpected.txt' - [System.IO.File]::WriteAllText($extraPath, 'unexpected') - Assert-Throw -Description 'extra extracted package file' -Action { - Assert-ObserverExtractedModulePackage -DirectoryPath $extractedRoot -ModuleName 'renpy' - } - Remove-Item -LiteralPath $extraPath - - $symbolsRoot = Join-Path $temporaryRoot 'symbols' - New-Item -ItemType Directory -Force -Path $symbolsRoot | Out-Null - foreach ($relativeName in $script:ObserverSymbolsPackageEntries) { - [System.IO.File]::WriteAllText((Join-Path $symbolsRoot $relativeName), $relativeName) - } - [void](Assert-ObserverExtractedSymbolsPackage -DirectoryPath $symbolsRoot) - - $modulePath = Join-Path $extractedRoot 'renpy.so' - $probePath = Join-Path $temporaryRoot 'package-smoke-probe.cmd' - [System.IO.File]::WriteAllLines($probePath, @( - '@echo off', - ('if not "%OBSERVER_PACKAGE_MODULE%"=="{0}" exit /b 21' -f $modulePath), - 'if not "%OBSERVER_PACKAGE_FORMAT%"=="renpy" exit /b 22', - 'exit /b 0' - )) - - $previousModule = 'module-sentinel' - $previousFormat = 'format-sentinel' - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $previousModule, 'Process') - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $previousFormat, 'Process') - - Invoke-ObserverPackageRuntimeSmoke ` - -TestExecutablePath $probePath ` - -ModulePath $modulePath ` - -ModuleName 'renpy' ` - -WorkingDirectory $temporaryRoot - - if ([Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', 'Process') -ne $previousModule) { - throw 'Package smoke did not restore OBSERVER_PACKAGE_MODULE.' - } - if ([Environment]::GetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', 'Process') -ne $previousFormat) { - throw 'Package smoke did not restore OBSERVER_PACKAGE_FORMAT.' - } -} -finally { - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_MODULE', $null, 'Process') - [Environment]::SetEnvironmentVariable('OBSERVER_PACKAGE_FORMAT', $null, 'Process') - if (Test-Path -LiteralPath $temporaryRoot) { - $item = Get-Item -LiteralPath $temporaryRoot -Force - if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing to remove reparse-point test directory: $temporaryRoot" - } - Remove-Item -LiteralPath $temporaryRoot -Recurse -Force - } -} - -$orchestrationFiles = @( - Get-Item -LiteralPath $buildEntrypoint - Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name -) -$packageDefinitions = @( - foreach ($file in $orchestrationFiles) { - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile( - $file.FullName, - [ref] $tokens, - [ref] $errors - ) - if ($errors.Count -ne 0) { - throw "PowerShell parse failed for '$($file.FullName)'." - } - foreach ($definition in $ast.FindAll( - { - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq 'Invoke-Package' - }, - $false - )) { - [pscustomobject]@{ File = $file.FullName; Definition = $definition } - } - } -) -if ($packageDefinitions.Count -ne 1) { - throw "Package orchestration must define Invoke-Package exactly once; found $($packageDefinitions.Count)." -} -$packageSource = $packageDefinitions[0].Definition.Extent.Text -foreach ($requiredCall in @( - 'Expand-ObserverModulePackageForSmoke', - 'Expand-ObserverSymbolsPackageForSmoke', - 'Invoke-ObserverPackageRuntimeSmoke' - )) { - if (-not $packageSource.Contains($requiredCall, [System.StringComparison]::Ordinal)) { - throw "Package orchestration does not invoke $requiredCall." - } -} -if ($packageSource.Contains('$PSScriptRoot', [System.StringComparison]::Ordinal)) { - throw 'Invoke-Package must use the caller-provided build context instead of its physical source location.' -} - -Write-Host 'Package smoke contracts passed.' diff --git a/build/tests/test_dynamic_graph.py b/build/tests/test_dynamic_graph.py deleted file mode 100644 index 66da9f1..0000000 --- a/build/tests/test_dynamic_graph.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -from copy import deepcopy -import json -from pathlib import Path -from pathlib import PurePosixPath -import unittest - -from build.graph_driver import Graph -from build.native_graph import ( - FUZZ_PROJECT_NAMES, - build_project_node_name, - expand_x64_nodes, - fuzz_build_node_name, - load_manifest, -) -from build.dynamic_graph import ( - ARCHITECTURES, - FUZZ_TARGETS, - LEAK_MODES, - LEAK_SCENARIOS, - MODULES, - DynamicTopologyError, - build_dynamic_topology, - validate_dynamic_topology, -) - -WORKSPACE = Path(__file__).resolve().parents[2] - - -class DynamicGraphTopologyTests(unittest.TestCase): - def setUp(self) -> None: - self.topology = build_dynamic_topology( - architectures=ARCHITECTURES, - host_architecture="x64", - leak_windows=3, - run_id="contract-run", - ) - validate_dynamic_topology(self.topology) - self.nodes = {node["name"]: node for node in self.topology["nodes"]} - - def nodes_of_kind(self, kind: str) -> list[dict[str, object]]: - return [ - node - for node in self.topology["nodes"] - if f"kind={kind}" in node["fingerprint"] - ] - - def test_schema_is_normalized_and_stable(self) -> None: - self.assertEqual(2, self.topology["schema"]) - self.assertEqual( - sorted(self.nodes), - [node["name"] for node in self.topology["nodes"]], - ) - self.assertEqual( - json.dumps(self.topology, sort_keys=True, separators=(",", ":")), - json.dumps( - build_dynamic_topology( - architectures=ARCHITECTURES, - host_architecture="x64", - leak_windows=3, - run_id="contract-run", - ), - sort_keys=True, - separators=(",", ":"), - ), - ) - for node in self.topology["nodes"]: - self.assertEqual( - { - "name", - "deps", - "run_after", - "argv", - "resources", - "inputs", - "writes", - "outputs", - "fingerprint", - "cacheable", - }, - set(node), - ) - self.assertTrue(node["resources"], node["name"]) - self.assertIsInstance(node["resources"], dict) - self.assertEqual([], node["run_after"]) - for resource, units in node["resources"].items(): - self.assertTrue(resource) - self.assertGreater(units, 0) - - def test_every_write_and_output_has_one_owner(self) -> None: - write_owners: dict[str, str] = {} - output_owners: dict[str, str] = {} - for node in self.topology["nodes"]: - for field, owners in (("writes", write_owners), ("outputs", output_owners)): - for value in node[field]: - self.assertFalse(PurePosixPath(value).is_absolute(), value) - self.assertNotIn("..", PurePosixPath(value).parts, value) - self.assertNotIn(value, owners, f"{value}: {owners.get(value)} and {node['name']}") - owners[value] = node["name"] - - def test_validation_rejects_nested_write_ownership_and_external_collisions(self) -> None: - nested = deepcopy(self.topology) - first, second = nested["nodes"][:2] - second["writes"].append(first["writes"][0] + "/child") - with self.assertRaisesRegex(DynamicTopologyError, "overlap"): - validate_dynamic_topology(nested) - - collision = deepcopy(self.topology) - collision["external_nodes"].append(collision["nodes"][0]["name"]) - with self.assertRaisesRegex(DynamicTopologyError, "external"): - validate_dynamic_topology(collision) - - def test_fuzz_extends_four_native_build_nodes_with_replay_and_timed_pipelines(self) -> None: - native_names = { - node["name"] - for node in expand_x64_nodes( - load_manifest(WORKSPACE, project_names=FUZZ_PROJECT_NAMES) - ) - } - self.assertEqual(0, len(self.nodes_of_kind("fuzz-build"))) - self.assertEqual(4, len(self.nodes_of_kind("fuzz-seed-replay"))) - self.assertEqual(4, len(self.nodes_of_kind("fuzz-timed"))) - for target in FUZZ_TARGETS: - build_name = fuzz_build_node_name(target) - replay = self.nodes[f"fuzz-seed-replay-x64-{target}"] - timed = self.nodes[f"fuzz-timed-x64-{target}"] - self.assertIn(build_name, self.topology["external_nodes"]) - self.assertIn(build_name, native_names) - self.assertEqual([build_name], replay["deps"]) - self.assertEqual([replay["name"]], timed["deps"]) - self.assertFalse(replay["cacheable"]) - self.assertFalse(timed["cacheable"]) - self.assertTrue(all("contract-run" in path for path in replay["outputs"])) - self.assertTrue(all("contract-run" in path for path in timed["outputs"])) - self.assertIn("-RunId", replay["argv"]) - self.assertIn("contract-run", replay["argv"]) - self.assertIn("-TargetName", replay["argv"]) - self.assertIn(target, replay["argv"]) - self.assertIn("-Phase", timed["argv"]) - self.assertIn("timed", timed["argv"]) - self.assertEqual(1, timed["resources"][f"fuzz-writer-x64-{target}"]) - - def test_leaks_are_fourteen_sessions_with_parallel_offline_analysis(self) -> None: - self.assertEqual(14, len(self.nodes_of_kind("leak-preflight"))) - self.assertEqual(14, len(self.nodes_of_kind("leak-capture"))) - self.assertEqual(42, len(self.nodes_of_kind("leak-diff"))) - self.assertEqual(14, len(self.nodes_of_kind("leak-judge"))) - for mode in LEAK_MODES: - for scenario in LEAK_SCENARIOS: - stem = f"leak-{mode}-{scenario}" - preflight = self.nodes[f"{stem}-preflight"] - capture = self.nodes[f"{stem}-capture"] - judge = self.nodes[f"{stem}-judge"] - self.assertEqual(["leak-setup"], preflight["deps"]) - self.assertEqual([preflight["name"]], capture["deps"]) - self.assertIn("-Scenario", capture["argv"]) - self.assertIn(scenario, capture["argv"]) - self.assertEqual(1, capture["resources"]["umdh-capture"]) - expected_diffs = sorted( - [f"{stem}-diff-window-1", f"{stem}-diff-window-2", f"{stem}-diff-overall"] - ) - self.assertEqual(expected_diffs, judge["deps"]) - aggregate = self.nodes["leaks-aggregate"] - self.assertEqual(14, len(aggregate["deps"])) - - def test_audit_is_per_architecture_module_and_tool(self) -> None: - self.assertEqual(9, len(self.nodes_of_kind("audit-dumpbin"))) - self.assertEqual(9, len(self.nodes_of_kind("audit-binskim"))) - self.assertEqual(9, len(self.nodes_of_kind("audit-module"))) - for architecture in ARCHITECTURES: - for module in MODULES: - stem = f"audit-{architecture}-{module}" - release = ( - build_project_node_name("Release", module) - if architecture == "x64" - else f"release-{architecture}-{module}" - ) - self.assertEqual([release], self.nodes[f"{stem}-dumpbin"]["deps"]) - self.assertEqual([release], self.nodes[f"{stem}-binskim"]["deps"]) - self.assertEqual( - sorted([f"{stem}-dumpbin", f"{stem}-binskim"]), - self.nodes[stem]["deps"], - ) - - def test_package_has_creation_content_and_runtime_leaf_per_module_archive(self) -> None: - self.assertEqual(9, len(self.nodes_of_kind("package-create"))) - self.assertEqual(9, len(self.nodes_of_kind("package-content-smoke"))) - self.assertEqual(6, len(self.nodes_of_kind("package-runtime-smoke"))) - self.assertEqual(3, len(self.nodes_of_kind("package-runtime-deferred"))) - self.assertEqual(3, len(self.nodes_of_kind("symbols-create"))) - self.assertEqual(3, len(self.nodes_of_kind("symbols-content-smoke"))) - for architecture in ARCHITECTURES: - for module in MODULES: - stem = f"package-{architecture}-{module}" - create = self.nodes[f"{stem}-create"] - content = self.nodes[f"{stem}-content"] - runtime = self.nodes[f"{stem}-runtime"] - self.assertEqual( - sorted(["package-init", f"audit-{architecture}-{module}"]), - create["deps"], - ) - self.assertEqual([create["name"]], content["deps"]) - expected_runtime_kind = ( - "package-runtime-smoke" if architecture in {"x86", "x64"} else "package-runtime-deferred" - ) - self.assertIn(f"kind={expected_runtime_kind}", runtime["fingerprint"]) - expected_runtime_deps = [content["name"]] - if expected_runtime_kind == "package-runtime-smoke": - expected_runtime_deps.append( - build_project_node_name("Release", "tests") - if architecture == "x64" - else f"release-{architecture}-tests" - ) - self.assertEqual(sorted(expected_runtime_deps), runtime["deps"]) - evidence = self.nodes["package-evidence"] - self.assertEqual(12, len(evidence["deps"])) - - def test_x64_release_anchors_are_exported_by_the_native_generator(self) -> None: - native_names = { - node["name"] for node in expand_x64_nodes(load_manifest(WORKSPACE)) - } - expected = { - build_project_node_name("Release", project) - for project in (*MODULES, "tests", "leak-probe") - } - self.assertTrue(expected.issubset(native_names)) - self.assertTrue(expected.issubset(set(self.topology["external_nodes"]))) - self.assertIn( - build_project_node_name("Release", "leak-probe"), - self.nodes["leak-setup"]["deps"], - ) - - def test_x64_dynamic_records_compose_with_schema_v2_native_records(self) -> None: - dynamic = build_dynamic_topology( - architectures=("x64",), - host_architecture="x64", - leak_windows=3, - run_id="schema-v2-integration", - ) - native_nodes = expand_x64_nodes(load_manifest(WORKSPACE)) - resource_names = { - resource - for node in (*native_nodes, *dynamic["nodes"]) - for resource in node["resources"] - } - capacities = {resource: 64 for resource in resource_names} - source_checks = { - "name": "source-checks", - "deps": [], - "run_after": [], - "resources": {"cpu": 1}, - "argv": ["pwsh", "-NoProfile", "-Command", "exit 0"], - "inputs": [], - "writes": [], - "outputs": [], - "fingerprint": ["contract=test-source-checks"], - "cacheable": False, - } - Graph.from_mapping( - "native-dynamic-integration", - { - "resources": capacities, - "failure_policy": "continue", - "targets": ["leaks-aggregate", "package-evidence"], - "nodes": [source_checks, *native_nodes, *dynamic["nodes"]], - }, - WORKSPACE, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/build/tests/test_graph_driver.py b/build/tests/test_graph_driver.py deleted file mode 100644 index 386048c..0000000 --- a/build/tests/test_graph_driver.py +++ /dev/null @@ -1,1023 +0,0 @@ -from __future__ import annotations - -import importlib.util -import io -import json -from pathlib import Path -import subprocess -import sys -import tempfile -import threading -import time -import types -import unittest -from unittest import mock - - -BUILD_ROOT = Path(__file__).resolve().parents[1] -REPOSITORY_ROOT = BUILD_ROOT.parent -DRIVER_PATH = BUILD_ROOT / "graph_driver.py" -ARTIFACTS_ROOT = REPOSITORY_ROOT / ".artifacts" -ARTIFACTS_ROOT.mkdir(exist_ok=True) - - -def load_driver() -> types.ModuleType: - spec = importlib.util.spec_from_file_location("observer_graph_driver", DRIVER_PATH) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load graph driver from {DRIVER_PATH}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -driver = load_driver() - - -def node( - name: str, - *, - deps: tuple[str, ...] = (), - run_after: tuple[str, ...] = (), - resources: dict[str, int] | None = None, - argv: tuple[str, ...] = ("synthetic",), - inputs: tuple[str, ...] = (), - writes: tuple[str, ...] = (), - outputs: tuple[str, ...] = (), - fingerprint: tuple[str, ...] = (), - cacheable: bool = False, -) -> dict[str, object]: - return { - "name": name, - "deps": list(deps), - "run_after": list(run_after), - "resources": resources or {"cpu": 1}, - "argv": list(argv), - "inputs": list(inputs), - "writes": list(writes), - "outputs": list(outputs), - "fingerprint": list(fingerprint), - "cacheable": cacheable, - } - - -class GraphDriverTests(unittest.TestCase): - def setUp(self) -> None: - self.temporary_directory = tempfile.TemporaryDirectory( - dir=ARTIFACTS_ROOT, - prefix="graph-driver-tests-", - ) - self.workspace = Path(self.temporary_directory.name) - self.logs = self.workspace / "logs" - self.state = self.workspace / "state" - - def tearDown(self) -> None: - for attempt in range(5): - try: - self.temporary_directory.cleanup() - return - except OSError: - if attempt == 4: - raise - time.sleep(0.02) - - def graph( - self, - nodes: list[dict[str, object]], - *, - resources: dict[str, int] | None = None, - targets: list[str] | None = None, - failure_policy: str = "continue", - ): - mapping = { - "failure_policy": failure_policy, - "resources": resources or {"cpu": 1}, - "targets": targets or [str(nodes[-1]["name"])], - "nodes": nodes, - } - return driver.Graph.from_mapping("synthetic", mapping, self.workspace) - - def write_profile(self, graph: dict[str, object]) -> Path: - path = self.workspace / "profile.json" - path.write_text( - json.dumps( - { - "schema": 2, - "default_graph": "synthetic", - "graphs": {"synthetic": graph}, - } - ), - encoding="utf-8", - ) - return path - - def test_cycle_detection_reports_a_deterministic_cycle(self) -> None: - graph = self.graph( - [ - node("c", deps=("a",)), - node("a", deps=("b",)), - node("b", deps=("c",)), - ], - targets=["a"], - ) - - with self.assertRaisesRegex(driver.CycleError, r"a -> b -> c -> a"): - graph.plan() - - def test_topological_plan_is_deterministic_across_declaration_order(self) -> None: - declarations = [ - node("d", deps=("a", "c")), - node("c", deps=("b",)), - node("b"), - node("a"), - ] - expected = ["a", "b", "c", "d"] - - first = self.graph(declarations, targets=["d"]) - second = self.graph(list(reversed(declarations)), targets=["d"]) - - self.assertEqual([item.node.name for item in first.plan()], expected) - self.assertEqual([item.node.name for item in second.plan()], expected) - - def test_unknown_graph_level_field_is_rejected(self) -> None: - mapping = { - "failure_policy": "continue", - "resources": {"cpu": 1}, - "targets": ["safe"], - "nodes": [node("safe")], - "resoruces": {"typo": 1}, - } - - with self.assertRaisesRegex(driver.GraphValidationError, "unknown graph fields"): - driver.Graph.from_mapping("synthetic", mapping, self.workspace) - - def test_workspace_run_lock_excludes_a_second_process(self) -> None: - probe = """ -import importlib.util -from pathlib import Path -import sys - -spec = importlib.util.spec_from_file_location("lock_probe_driver", sys.argv[1]) -module = importlib.util.module_from_spec(spec) -sys.modules[spec.name] = module -spec.loader.exec_module(module) -try: - with module._WorkspaceRunLock(Path(sys.argv[2])): - pass -except module.GraphValidationError: - raise SystemExit(0) -raise SystemExit(1) -""" - - with driver._WorkspaceRunLock(self.workspace): - completed = subprocess.run( - [sys.executable, "-c", probe, str(DRIVER_PATH), str(self.workspace)], - check=False, - capture_output=True, - text=True, - ) - - self.assertEqual(completed.returncode, 0, completed.stderr) - - def test_graph_runner_holds_workspace_lock_for_the_entire_run(self) -> None: - graph = self.graph([node("gate")]) - runner = driver.GraphRunner(graph, self.logs, self.state, max_workers=1) - - with driver._WorkspaceRunLock(self.workspace): - with self.assertRaisesRegex(driver.GraphValidationError, "already running"): - runner.run() - - def test_continue_policy_blocks_dependents_but_runs_independent_nodes(self) -> None: - graph = self.graph( - [ - node("failure"), - node("dependent", deps=("failure",)), - node("transitive", deps=("dependent",)), - node("independent"), - ], - resources={"cpu": 4}, - targets=["transitive", "independent"], - ) - executed: list[str] = [] - lock = threading.Lock() - - def execute(current, _workspace, stream, _cancellation) -> int: - with lock: - executed.append(current.name) - stream.write(f"executed {current.name}\n") - return 19 if current.name == "failure" else 0 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=4, - executor=execute, - ).run() - - self.assertEqual(summary.results["failure"].status, "failed") - self.assertEqual(summary.results["dependent"].status, "blocked") - self.assertEqual(summary.results["transitive"].status, "blocked") - self.assertEqual(summary.results["independent"].status, "succeeded") - self.assertCountEqual(executed, ["failure", "independent"]) - - def test_order_only_finalizers_run_after_failure_while_dependents_block(self) -> None: - graph = self.graph( - [ - node("analyzer", writes=("raw",)), - node("normalizer", run_after=("analyzer",), writes=("sarif",)), - node("ordinary", deps=("analyzer",)), - node( - "final", - deps=("normalizer",), - run_after=("analyzer",), - inputs=("sarif",), - ), - ], - resources={"cpu": 2}, - targets=["ordinary", "final"], - ) - executed: list[str] = [] - - def execute(current, workspace, stream, _cancellation) -> int: - executed.append(current.name) - if current.name == "analyzer": - (workspace / "raw").write_text("diagnostic", encoding="utf-8") - return 9 - if current.name == "normalizer": - (workspace / "sarif").write_text("normalized diagnostic", encoding="utf-8") - return 0 - if current.name == "final": - stream.write((workspace / "sarif").read_text(encoding="utf-8")) - return 17 - return 0 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=2, - executor=execute, - ).run() - - self.assertEqual(executed, ["analyzer", "normalizer", "final"]) - self.assertEqual(summary.results["analyzer"].status, "failed") - self.assertEqual(summary.results["normalizer"].status, "succeeded") - self.assertEqual(summary.results["ordinary"].status, "blocked") - self.assertEqual(summary.results["final"].status, "failed") - self.assertIn("normalized diagnostic", summary.results["final"].log_path.read_text(encoding="utf-8")) - - def test_fail_fast_cancels_every_node_that_has_not_started(self) -> None: - graph = self.graph( - [ - node("a-failure"), - node("b-dependent", deps=("a-failure",)), - node("c-independent"), - ], - targets=["b-dependent", "c-independent"], - failure_policy="fail-fast", - ) - executed: list[str] = [] - - def execute(current, _workspace, stream, _cancellation) -> int: - executed.append(current.name) - stream.write(current.name) - return 7 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ).run() - - self.assertEqual(executed, ["a-failure"]) - self.assertEqual(summary.results["a-failure"].status, "failed") - self.assertEqual(summary.results["b-dependent"].status, "cancelled") - self.assertEqual(summary.results["c-independent"].status, "cancelled") - self.assertFalse(summary.succeeded) - - def test_fail_fast_signals_already_running_executors_through_the_cancellation_seam(self) -> None: - graph = self.graph( - [node("a-failure"), node("b-running")], - resources={"cpu": 2}, - targets=["a-failure", "b-running"], - failure_policy="fail-fast", - ) - both_started = threading.Barrier(2) - running_saw_cancellation = threading.Event() - - def execute(current, _workspace, stream, cancellation) -> int: - both_started.wait(timeout=2) - if current.name == "a-failure": - return 23 - if cancellation.wait(timeout=2): - running_saw_cancellation.set() - stream.write("running node stopped\n") - return 0 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=2, - executor=execute, - ).run() - - self.assertTrue(running_saw_cancellation.is_set()) - self.assertEqual(summary.results["a-failure"].status, "failed") - self.assertEqual(summary.results["b-running"].status, "cancelled") - - def test_weighted_resources_are_acquired_and_released_atomically(self) -> None: - declarations = [ - node("compile-a", resources={"cpu": 2, "toolchain": 1}), - node("compile-b", resources={"cpu": 2, "toolchain": 1}), - node("report", resources={"cpu": 1, "report-io": 1}), - ] - capacities = {"cpu": 3, "toolchain": 1, "report-io": 1} - graph = self.graph( - declarations, - resources=capacities, - targets=[str(item["name"]) for item in declarations], - ) - active = {name: 0 for name in capacities} - maximum = {name: 0 for name in capacities} - maximum_processes = 0 - process_count = 0 - lock = threading.Lock() - - def execute(current, _workspace, stream, _cancellation) -> int: - nonlocal maximum_processes, process_count - demands = dict(current.resources) - with lock: - process_count += 1 - maximum_processes = max(maximum_processes, process_count) - for resource, demand in demands.items(): - active[resource] += demand - maximum[resource] = max(maximum[resource], active[resource]) - time.sleep(0.04) - with lock: - for resource, demand in demands.items(): - active[resource] -= demand - process_count -= 1 - stream.write(f"executed {current.name}\n") - return 0 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=3, - executor=execute, - ).run() - - self.assertTrue(summary.succeeded) - self.assertLessEqual(maximum["cpu"], capacities["cpu"]) - self.assertLessEqual(maximum["toolchain"], capacities["toolchain"]) - self.assertGreaterEqual(maximum_processes, 2) - - def test_resource_demand_cannot_exceed_capacity(self) -> None: - with self.assertRaisesRegex(driver.GraphValidationError, "exceeds capacity"): - self.graph( - [node("too-heavy", resources={"cpu": 3})], - resources={"cpu": 2}, - ) - - def test_zero_max_workers_is_rejected(self) -> None: - graph = self.graph([node("safe")]) - - with self.assertRaisesRegex(driver.GraphValidationError, "max_workers must be positive"): - driver.GraphRunner(graph, self.logs, self.state, max_workers=0) - - def test_ready_queue_preserves_age_when_new_lexical_predecessors_arrive(self) -> None: - graph = self.graph( - [ - node("a0"), - node("a1", deps=("a0",)), - node("a2", deps=("a1",)), - node("z-old"), - ], - targets=["a2", "z-old"], - ) - executed: list[str] = [] - - def execute(current, _workspace, stream, _cancellation) -> int: - executed.append(current.name) - stream.write(current.name) - return 0 - - driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ).run() - - self.assertEqual(executed, ["a0", "z-old", "a1", "a2"]) - - def test_ready_queue_backfills_a_node_using_disjoint_resources(self) -> None: - graph = self.graph( - [ - node("a-hold", resources={"serial": 1}), - node("b-wait", resources={"serial": 1}), - node("c-other", resources={"other": 1}), - ], - resources={"serial": 1, "other": 1}, - targets=["a-hold", "b-wait", "c-other"], - ) - started: list[str] = [] - active = 0 - maximum = 0 - lock = threading.Lock() - - def execute(current, _workspace, stream, _cancellation) -> int: - nonlocal active, maximum - with lock: - started.append(current.name) - active += 1 - maximum = max(maximum, active) - time.sleep(0.04) - with lock: - active -= 1 - stream.write(current.name) - return 0 - - driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=2, - executor=execute, - ).run() - - self.assertEqual(started[0], "a-hold") - self.assertIn("c-other", started[:2]) - self.assertEqual(maximum, 2) - - def test_unordered_overlapping_write_roots_are_rejected(self) -> None: - graph = self.graph( - [ - node("first", writes=("out/shared",)), - node("second", writes=("out/shared/child",)), - ], - resources={"cpu": 2}, - targets=["first", "second"], - ) - - with self.assertRaisesRegex(driver.GraphValidationError, "overlapping writes"): - graph.plan() - - def test_dependency_order_allows_overlapping_write_roots(self) -> None: - graph = self.graph( - [ - node("first", writes=("out/shared",)), - node("second", deps=("first",), writes=("out/shared/child",)), - ], - targets=["second"], - ) - - self.assertEqual([item.node.name for item in graph.plan()], ["first", "second"]) - - def test_shared_capacity_one_resource_allows_overlapping_write_roots(self) -> None: - graph = self.graph( - [ - node("first", resources={"cpu": 1, "staging": 1}, writes=("out/shared",)), - node("second", resources={"cpu": 1, "staging": 1}, writes=("out/shared",)), - ], - resources={"cpu": 2, "staging": 1}, - targets=["first", "second"], - ) - - self.assertEqual(len(graph.plan()), 2) - - def test_shared_capacity_two_resource_does_not_protect_overlapping_writes(self) -> None: - graph = self.graph( - [ - node("first", resources={"staging": 1}, writes=("out/shared",)), - node("second", resources={"staging": 1}, writes=("out/shared",)), - ], - resources={"staging": 2}, - targets=["first", "second"], - ) - - with self.assertRaisesRegex(driver.GraphValidationError, "overlapping writes"): - graph.plan() - - def test_outputs_must_be_confined_below_a_declared_write_root(self) -> None: - with self.assertRaisesRegex(driver.GraphValidationError, "declared write root"): - self.graph( - [ - node( - "invalid", - writes=("out/owned",), - outputs=("out/elsewhere/result.txt",), - cacheable=True, - ) - ] - ) - - def test_graph_paths_and_runtime_directories_cannot_escape_workspace(self) -> None: - with self.assertRaisesRegex(driver.GraphValidationError, "unsafe input"): - self.graph([node("traversal", inputs=("../outside.txt",))]) - with self.assertRaisesRegex(driver.GraphValidationError, "unsafe write"): - self.graph([node("absolute", writes=("C:/outside",))]) - - graph = self.graph([node("safe")]) - with self.assertRaisesRegex(driver.GraphValidationError, "inside the workspace"): - driver.GraphRunner(graph, self.workspace.parent / "logs", self.state) - - def test_cacheable_node_requires_explicit_outputs(self) -> None: - with self.assertRaisesRegex(driver.GraphValidationError, "explicit outputs"): - self.graph([node("invalid", cacheable=True)]) - - def test_failed_rerun_invalidates_old_state_before_partial_output(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("output.txt",), - outputs=("output.txt",), - fingerprint=("tool=v1",), - cacheable=True, - ) - ] - ) - attempts = 0 - - def execute(_current, workspace, stream, _cancellation) -> int: - nonlocal attempts - attempts += 1 - (workspace / "output.txt").write_text(f"attempt={attempts}", encoding="utf-8") - stream.write(f"attempt={attempts}\n") - return 1 if attempts == 2 else 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - first = runner.run() - (self.workspace / "output.txt").unlink() - failed = runner.run() - recovered = runner.run() - - self.assertEqual(first.results["producer"].status, "succeeded") - self.assertEqual(failed.results["producer"].status, "failed") - self.assertEqual(recovered.results["producer"].status, "succeeded") - self.assertEqual(attempts, 3) - - def test_output_content_tampering_invalidates_cache(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("output.txt",), - outputs=("output.txt",), - cacheable=True, - ) - ] - ) - attempts = 0 - - def execute(_current, workspace, stream, _cancellation) -> int: - nonlocal attempts - attempts += 1 - (workspace / "output.txt").write_text(f"attempt={attempts}", encoding="utf-8") - stream.write(f"attempt={attempts}\n") - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - first = runner.run() - warm = runner.run() - (self.workspace / "output.txt").write_text("tampered", encoding="utf-8") - repaired = runner.run() - - self.assertEqual(first.results["producer"].status, "succeeded") - self.assertEqual(warm.results["producer"].status, "cached") - self.assertEqual(repaired.results["producer"].status, "succeeded") - self.assertEqual(attempts, 2) - state = json.loads((self.state / "producer.json").read_text(encoding="utf-8")) - self.assertEqual(state["schema"], 2) - self.assertEqual(state["outputs"][0]["path"], "output.txt") - self.assertIn("sha256", state["outputs"][0]) - - def test_successful_cacheable_node_hashes_its_output_manifest_once(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("output.txt",), - outputs=("output.txt",), - cacheable=True, - ) - ] - ) - - def execute(_current, workspace, stream, _cancellation) -> int: - (workspace / "output.txt").write_text("content", encoding="utf-8") - stream.write("produced") - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - with mock.patch.object(runner, "_output_manifest", wraps=runner._output_manifest) as manifest: - summary = runner.run() - - self.assertEqual(summary.results["producer"].status, "succeeded") - self.assertEqual(manifest.call_count, 1) - - def test_save_failure_becomes_failed_node_and_leaves_no_cache_state(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("output.txt",), - outputs=("output.txt",), - cacheable=True, - ) - ] - ) - - def execute(_current, workspace, _stream, _cancellation) -> int: - (workspace / "output.txt").write_text("content", encoding="utf-8") - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - with mock.patch.object(runner, "_save", side_effect=OSError("state disk full")): - summary = runner.run() - - result = summary.results["producer"] - self.assertEqual(result.status, "failed") - self.assertIn("state disk full", result.detail) - self.assertFalse((self.state / "producer.json").exists()) - - def test_missing_manifest_output_is_a_failed_node_with_invalidated_state(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("output.txt",), - outputs=("output.txt",), - cacheable=True, - ) - ] - ) - self.state.mkdir() - (self.state / "producer.json").write_text("stale", encoding="utf-8") - - def execute(_current, _workspace, _stream, _cancellation) -> int: - return 0 - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ).run() - - self.assertEqual(summary.results["producer"].status, "failed") - self.assertFalse((self.state / "producer.json").exists()) - - def test_ready_cache_validation_runs_outside_the_dispatch_loop(self) -> None: - declarations = [ - node( - name, - writes=(f"{name}.txt",), - outputs=(f"{name}.txt",), - cacheable=True, - ) - for name in ("first", "second") - ] - graph = self.graph( - declarations, - resources={"cpu": 2}, - targets=["first", "second"], - ) - barrier = threading.Barrier(2) - - def validate(_item) -> bool: - barrier.wait(timeout=2) - return False - - def execute(current, workspace, _stream, _cancellation) -> int: - (workspace / f"{current.name}.txt").write_text(current.name, encoding="utf-8") - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=2, - executor=execute, - ) - with mock.patch.object(runner, "_cached", side_effect=validate): - summary = runner.run() - - self.assertTrue(summary.succeeded) - - def test_cache_validation_obeys_capacity_one_output_lock(self) -> None: - declarations = [ - node( - name, - resources={"shared-output": 1}, - writes=("shared",), - outputs=("shared",), - cacheable=True, - ) - for name in ("first", "second") - ] - graph = self.graph( - declarations, - resources={"shared-output": 1}, - targets=["first", "second"], - ) - active = 0 - maximum = 0 - lock = threading.Lock() - - def validate(_item) -> bool: - nonlocal active, maximum - with lock: - active += 1 - maximum = max(maximum, active) - time.sleep(0.04) - with lock: - active -= 1 - return True - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=2, - executor=lambda *_args: 0, - ) - with mock.patch.object(runner, "_cached", side_effect=validate): - summary = runner.run() - - self.assertTrue(summary.succeeded) - self.assertEqual(maximum, 1) - - def test_rebuilt_prerequisite_prevents_a_stale_dependent_cache_hit(self) -> None: - graph = self.graph( - [ - node( - "producer", - writes=("producer.txt",), - outputs=("producer.txt",), - cacheable=True, - ), - node( - "consumer", - deps=("producer",), - inputs=("producer.txt",), - writes=("consumer.txt",), - outputs=("consumer.txt",), - cacheable=True, - ), - ], - targets=["consumer"], - ) - attempts = {"producer": 0, "consumer": 0} - - def execute(current, workspace, stream, _cancellation) -> int: - attempts[current.name] += 1 - if current.name == "producer": - (workspace / "producer.txt").write_text( - f"producer={attempts[current.name]}", - encoding="utf-8", - ) - else: - source = (workspace / "producer.txt").read_text(encoding="utf-8") - (workspace / "consumer.txt").write_text(source, encoding="utf-8") - stream.write(current.name) - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - runner.run() - warm = runner.run() - (self.workspace / "producer.txt").write_text("tampered", encoding="utf-8") - repaired = runner.run() - - self.assertEqual(warm.results["producer"].status, "cached") - self.assertEqual(warm.results["consumer"].status, "cached") - self.assertEqual(repaired.results["producer"].status, "succeeded") - self.assertEqual(repaired.results["consumer"].status, "succeeded") - self.assertEqual(attempts, {"producer": 2, "consumer": 2}) - - def test_every_run_uses_a_unique_log_directory(self) -> None: - graph = self.graph([node("gate")]) - attempts = 0 - - def execute(_current, _workspace, stream, _cancellation) -> int: - nonlocal attempts - attempts += 1 - stream.write(f"attempt={attempts}\n") - return 0 - - runner = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - executor=execute, - ) - first = runner.run().results["gate"] - second = runner.run().results["gate"] - - self.assertNotEqual(first.log_path, second.log_path) - self.assertIn("attempt=1", first.log_path.read_text(encoding="utf-8")) - self.assertIn("attempt=2", second.log_path.read_text(encoding="utf-8")) - - def test_shared_input_is_hashed_only_once_per_plan(self) -> None: - (self.workspace / "input.txt").write_text("content", encoding="utf-8") - graph = self.graph( - [ - node("first", inputs=("input.txt",)), - node("second", inputs=("input.txt",)), - ], - resources={"cpu": 2}, - targets=["first", "second"], - ) - - with mock.patch.object(driver, "_sha256_file", wraps=driver._sha256_file) as digest: - graph.plan() - - self.assertEqual(digest.call_count, 1) - - def test_matrix_expansion_is_deterministic_and_applies_exact_excludes(self) -> None: - build_node = node( - "build-{arch}", - writes=("out/build/{arch}",), - ) - build_node["matrix"] = { - "axes": {"arch": ["x86", "x64"]}, - "exclude": [], - } - matrix_node = node( - "test-{arch}-{config}", - deps=("build-{arch}",), - resources={"cpu": 1}, - argv=("tool", "--arch", "{arch}", "--config", "{config}"), - writes=("out/{arch}/{config}",), - ) - matrix_node["matrix"] = { - "axes": {"config": ["Release", "Debug"], "arch": ["x64", "x86"]}, - "exclude": [{"arch": "x86", "config": "Release"}], - } - profile = self.write_profile( - { - "variables": {}, - "failure_policy": "continue", - "resources": {"cpu": 2}, - "targets": ["test-x64-Debug", "test-x64-Release", "test-x86-Debug"], - "nodes": [matrix_node, build_node], - } - ) - - graph = driver.load_graph_profile(profile, None, self.workspace) - - self.assertEqual( - sorted(graph.nodes), - ["build-x64", "build-x86", "test-x64-Debug", "test-x64-Release", "test-x86-Debug"], - ) - self.assertEqual(graph.nodes["test-x64-Debug"].argv, ("tool", "--arch", "x64", "--config", "Debug")) - self.assertEqual(graph.nodes["test-x64-Debug"].deps, ("build-x64",)) - self.assertEqual(graph.nodes["test-x86-Debug"].writes, ("out/x86/Debug",)) - - def test_matrix_axis_cannot_collide_with_graph_variable(self) -> None: - matrix_node = node("test-{arch}") - matrix_node["matrix"] = {"axes": {"arch": ["x64"]}, "exclude": []} - profile = self.write_profile( - { - "variables": {"arch": "x64"}, - "resources": {"cpu": 1}, - "targets": ["test-x64"], - "nodes": [matrix_node], - } - ) - - with self.assertRaisesRegex(driver.GraphValidationError, "collides with graph variable"): - driver.load_graph_profile(profile, None, self.workspace) - - def test_matrix_exclude_must_be_an_exact_known_assignment(self) -> None: - matrix_node = node("test-{arch}-{config}") - matrix_node["matrix"] = { - "axes": {"arch": ["x64", "x86"], "config": ["Debug", "Release"]}, - "exclude": [{"arch": "x86"}], - } - profile = self.write_profile( - { - "variables": {}, - "resources": {"cpu": 1}, - "targets": ["test-x64-Debug"], - "nodes": [matrix_node], - } - ) - - with self.assertRaisesRegex(driver.GraphValidationError, "exact assignment"): - driver.load_graph_profile(profile, None, self.workspace) - - def test_profile_schema_one_is_rejected_without_ambiguous_compatibility(self) -> None: - profile = self.workspace / "v1.json" - profile.write_text(json.dumps({"schema": 1, "graphs": {}}), encoding="utf-8") - - with self.assertRaisesRegex(driver.GraphValidationError, "profile schema must be 2"): - driver.load_graph_profile(profile, None, self.workspace) - - def test_legacy_pool_field_is_rejected(self) -> None: - legacy = node("legacy") - legacy["pool"] = "cpu" - - with self.assertRaisesRegex(driver.GraphValidationError, "legacy pool"): - self.graph([legacy]) - - def test_default_executor_uses_argv_without_a_shell(self) -> None: - graph = self.graph([node("safe", argv=("program", "argument with spaces"))]) - current = graph.nodes["safe"] - completed = subprocess.CompletedProcess(current.argv, 0) - - with mock.patch.object(driver.subprocess, "run", return_value=completed) as run: - return_code = driver.execute_subprocess( - current, - self.workspace, - io.StringIO(), - threading.Event(), - ) - - self.assertEqual(return_code, 0) - positional, keyword = run.call_args - self.assertEqual(positional[0], ["program", "argument with spaces"]) - self.assertIs(keyword["shell"], False) - - def test_default_runner_writes_stdout_and_stderr_to_per_node_log(self) -> None: - graph = self.graph( - [ - node( - "logged", - argv=( - sys.executable, - "-c", - "import sys; print('stdout-line'); print('stderr-line', file=sys.stderr)", - ), - ) - ] - ) - - summary = driver.GraphRunner( - graph, - self.logs, - self.state, - max_workers=1, - ).run() - - result = summary.results["logged"] - self.assertEqual(result.status, "succeeded") - log_text = result.log_path.read_text(encoding="utf-8") - self.assertIn("stdout-line", log_text) - self.assertIn("stderr-line", log_text) - - def test_json_plan_output_is_stable_and_normalized(self) -> None: - graph = self.graph([node("b"), node("a")], resources={"cpu": 2}, targets=["a", "b"]) - - first = driver.plan_as_json(graph.plan()) - second = driver.plan_as_json(graph.plan()) - - self.assertEqual(first, second) - parsed = json.loads(first) - self.assertEqual([item["name"] for item in parsed], ["a", "b"]) - self.assertEqual(parsed[0]["resources"], {"cpu": 1}) - self.assertEqual(parsed[0]["writes"], []) - self.assertNotIn("pool", parsed[0]) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/build/tests/test_ixdag_execute.py b/build/tests/test_ixdag_execute.py deleted file mode 100644 index 519518d..0000000 --- a/build/tests/test_ixdag_execute.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import os -from pathlib import Path -import sys -import tempfile -import unittest -from unittest import mock - -from build.ixdag.execute import ( - Command, - Executor, - Graph, - GraphError, - Node, - NodeExecutionError, - ProcessRequest, - run_process, -) - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -ARTIFACTS_ROOT = REPOSITORY_ROOT / ".artifacts" -ARTIFACTS_ROOT.mkdir(exist_ok=True) - - -def object_id(name: str) -> str: - return hashlib.sha256(name.encode("utf-8")).hexdigest() - - -def node( - name: str, - *, - deps: tuple[str, ...] = (), - pool: str = "cpu", - commands: tuple[Command, ...] | None = None, -) -> Node: - return Node( - name=name, - object_id=object_id(name), - deps=deps, - pool=pool, - commands=commands or (Command(("synthetic", name)),), - ) - - -class RecordingRunner: - def __init__(self, *, fail: str | None = None, delay: float = 0.0) -> None: - self.fail = fail - self.delay = delay - self.calls: list[ProcessRequest] = [] - self.active: dict[str, int] = {} - self.maximum: dict[str, int] = {} - - async def __call__(self, request: ProcessRequest) -> int: - self.calls.append(request) - pool = request.pool - self.active[pool] = self.active.get(pool, 0) + 1 - self.maximum[pool] = max(self.maximum.get(pool, 0), self.active[pool]) - try: - out_dir = Path(request.env["IX_OUT"]) - (out_dir / "payload.txt").write_text(request.node_name, encoding="utf-8") - if self.delay: - await asyncio.sleep(self.delay) - return 19 if request.node_name == self.fail else 0 - finally: - self.active[pool] -= 1 - - -class IxExecutorTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.temporary_directory = tempfile.TemporaryDirectory( - dir=ARTIFACTS_ROOT, - prefix="ixdag-tests-", - ) - self.workspace = Path(self.temporary_directory.name) - self.state = self.workspace / "state" - - def tearDown(self) -> None: - self.temporary_directory.cleanup() - - def graph( - self, - nodes: tuple[Node, ...], - *, - targets: tuple[str, ...], - pools: dict[str, int] | None = None, - ) -> Graph: - return Graph(nodes=nodes, targets=targets, pools=pools or {"cpu": 4}) - - async def test_demand_visit_deduplicates_a_shared_dependency(self) -> None: - graph = self.graph( - ( - node("shared"), - node("left", deps=("shared",)), - node("right", deps=("shared",)), - node("unreachable"), - ), - targets=("left", "right"), - ) - runner = RecordingRunner(delay=0.01) - - results = await Executor(graph, self.workspace, self.state, runner=runner).run() - - names = [request.node_name for request in runner.calls] - self.assertEqual(names.count("shared"), 1) - self.assertCountEqual(names, ["shared", "left", "right"]) - self.assertNotIn("unreachable", results) - - async def test_named_pool_semaphore_limits_concurrency(self) -> None: - graph = self.graph( - (node("first", pool="serial"), node("second", pool="serial")), - targets=("first", "second"), - pools={"serial": 1}, - ) - runner = RecordingRunner(delay=0.03) - - await Executor(graph, self.workspace, self.state, runner=runner).run() - - self.assertEqual(runner.maximum["serial"], 1) - self.assertEqual(runner.calls[0].env["IX_POOL_CAPACITY"], "1") - - async def test_complete_target_is_cached_without_visiting_its_dependencies(self) -> None: - graph = self.graph( - (node("dependency"), node("target", deps=("dependency",))), - targets=("target",), - ) - first_runner = RecordingRunner() - executor = Executor(graph, self.workspace, self.state, runner=first_runner) - await executor.run() - dependency_dir = executor.output_dir("dependency") - dependency_marker = dependency_dir / ".complete" - dependency_marker.unlink() - - warm_runner = RecordingRunner() - results = await Executor(graph, self.workspace, self.state, runner=warm_runner).run() - - self.assertEqual(results["target"].status, "cached") - self.assertEqual(warm_runner.calls, []) - self.assertFalse(dependency_marker.exists()) - - async def test_partial_output_is_moved_to_trash_before_rerun(self) -> None: - current = node("recover") - graph = self.graph((current,), targets=(current.name,)) - executor = Executor(graph, self.workspace, self.state, runner=RecordingRunner()) - out_dir = executor.output_dir(current.name) - out_dir.mkdir(parents=True) - (out_dir / "stale.txt").write_text("stale", encoding="utf-8") - - await executor.run() - - self.assertFalse((out_dir / "stale.txt").exists()) - trashed = list((self.state / "trash").glob("*")) - self.assertEqual(len(trashed), 1) - self.assertEqual((trashed[0] / "stale.txt").read_text(encoding="utf-8"), "stale") - self.assertTrue((out_dir / ".complete").is_file()) - - async def test_failed_node_is_trashed_and_does_not_publish_completion(self) -> None: - graph = self.graph( - (node("failure"), node("dependent", deps=("failure",))), - targets=("dependent",), - ) - runner = RecordingRunner(fail="failure") - executor = Executor(graph, self.workspace, self.state, runner=runner) - - with self.assertRaisesRegex(NodeExecutionError, "failure.*exit code 19"): - await executor.run() - - self.assertEqual([request.node_name for request in runner.calls], ["failure"]) - self.assertFalse(executor.output_dir("failure").exists()) - trashed_payloads = list((self.state / "trash").glob("*/payload.txt")) - self.assertEqual(len(trashed_payloads), 1) - self.assertEqual(trashed_payloads[0].read_text(encoding="utf-8"), "failure") - - async def test_failure_cancels_and_trashes_an_already_running_sibling(self) -> None: - graph = self.graph( - (node("failure"), node("slow")), - targets=("failure", "slow"), - ) - slow_started = asyncio.Event() - - async def runner(request: ProcessRequest) -> int: - out_dir = Path(request.env["IX_OUT"]) - (out_dir / "payload.txt").write_text(request.node_name, encoding="utf-8") - if request.node_name == "slow": - slow_started.set() - await asyncio.sleep(60) - return 0 - await slow_started.wait() - return 31 - - executor = Executor(graph, self.workspace, self.state, runner=runner) - - with self.assertRaisesRegex(NodeExecutionError, "failure.*exit code 31"): - await executor.run() - - self.assertFalse(executor.output_dir("failure").exists()) - self.assertFalse(executor.output_dir("slow").exists()) - trashed = list((self.state / "trash").glob("*/payload.txt")) - self.assertCountEqual( - [path.read_text(encoding="utf-8") for path in trashed], - ["failure", "slow"], - ) - - async def test_command_cwd_cannot_escape_the_workspace(self) -> None: - unsafe = node("unsafe", commands=(Command(("tool",), cwd=".."),)) - graph = self.graph((unsafe,), targets=(unsafe.name,)) - runner = RecordingRunner() - - with self.assertRaisesRegex(GraphError, "cwd must stay within workspace"): - await Executor(graph, self.workspace, self.state, runner=runner).run() - - self.assertEqual(runner.calls, []) - - async def test_default_process_runner_passes_literal_argv_without_a_shell(self) -> None: - process = mock.AsyncMock() - process.wait.return_value = 0 - request = ProcessRequest( - node_name="literal", - pool="cpu", - argv=("program", "argument with spaces", "¬-a-command"), - cwd=self.workspace, - env=dict(os.environ), - log_path=self.workspace / "literal.log", - ) - - with mock.patch.object( - asyncio, - "create_subprocess_exec", - return_value=process, - ) as create: - return_code = await run_process(request) - - self.assertEqual(return_code, 0) - positional, keyword = create.call_args - self.assertEqual(positional, request.argv) - self.assertNotIn("shell", keyword) - self.assertEqual(keyword["cwd"], str(self.workspace)) - - async def test_default_runner_merges_stdout_and_stderr_into_the_node_log(self) -> None: - logged = node( - "logged", - commands=( - Command( - ( - sys.executable, - "-c", - "import sys; print('stdout-line'); print('stderr-line', file=sys.stderr)", - ) - ), - ), - ) - graph = self.graph((logged,), targets=(logged.name,)) - - results = await Executor(graph, self.workspace, self.state).run() - - text = results["logged"].log_path.read_text(encoding="utf-8") - self.assertIn("stdout-line", text) - self.assertIn("stderr-line", text) - - async def test_cycle_is_rejected_instead_of_deadlocking_on_a_node_lock(self) -> None: - graph = self.graph( - (node("a", deps=("b",)), node("b", deps=("a",))), - targets=("a",), - ) - - with self.assertRaisesRegex(GraphError, "a -> b -> a"): - Executor(graph, self.workspace, self.state) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/build/tests/test_ixdag_graph.py b/build/tests/test_ixdag_graph.py deleted file mode 100644 index 59bdc74..0000000 --- a/build/tests/test_ixdag_graph.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from dataclasses import FrozenInstanceError -from pathlib import Path -from tempfile import TemporaryDirectory -import unittest - -from build.ixdag.graph import ( - Graph, - GraphError, - Node, - ObserverMatrix, - Project, - TranslationUnit, - build_observer_microdag, - direct_renderer, -) - - -class IxDagNodeTests(unittest.TestCase): - def test_node_is_typed_immutable_and_has_one_named_pool(self) -> None: - node = Node( - name="compile-a", - deps=(), - pool="msvc", - argv=("cl", "/c", "src/a.cpp"), - inputs=("src/a.cpp",), - outputs=(".artifacts/a.obj",), - ) - - self.assertEqual("msvc", node.pool) - with self.assertRaises(FrozenInstanceError): - node.pool = "other" # type: ignore[misc] - with self.assertRaisesRegex(GraphError, "glob"): - Node("bad", (), "cpu", ("tool",), ("src/*.cpp",), ("out",)) - with self.assertRaisesRegex(GraphError, "relative"): - Node("bad", (), "cpu", ("tool",), ("../src/a.cpp",), ("out",)) - - def test_content_uid_changes_with_source_bytes_and_flows_to_consumers(self) -> None: - with TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - source = root / "source.txt" - source.write_text("first", encoding="utf-8") - graph = Graph( - nodes=( - Node("producer", (), "cpu", ("copy",), ("source.txt",), ("out/a",)), - Node("consumer", ("producer",), "cpu", ("use",), ("out/a",), ("out/b",)), - ), - targets=("consumer",), - ) - - first = graph.descriptors(root) - source.write_text("second", encoding="utf-8") - second = graph.descriptors(root) - - self.assertNotEqual(first["producer"]["uid"], second["producer"]["uid"]) - self.assertNotEqual(first["consumer"]["uid"], second["consumer"]["uid"]) - self.assertEqual("producer", first["consumer"]["deps"][0]) - - def test_graph_rejects_hidden_generated_inputs_and_duplicate_outputs(self) -> None: - with self.assertRaisesRegex(GraphError, "direct dependency"): - Graph( - nodes=( - Node("a", (), "cpu", ("a",), (), ("out/a",)), - Node("b", (), "cpu", ("b",), ("out/a",), ("out/b",)), - ), - targets=("b",), - ) - with self.assertRaisesRegex(GraphError, "output owner"): - Graph( - nodes=( - Node("a", (), "cpu", ("a",), (), ("out/shared",)), - Node("b", (), "cpu", ("b",), (), ("out/shared",)), - ), - targets=("b",), - ) - - -class IxDagObserverMatrixTests(unittest.TestCase): - def setUp(self) -> None: - self.temporary_directory = TemporaryDirectory() - self.root = Path(self.temporary_directory.name) - paths = ( - "build/shared.props", - "build/projects/module.vcxproj", - "build/projects/tests.vcxproj", - "build/projects/fuzz-pickle.vcxproj", - "build/projects/leak-probe.vcxproj", - "src/module/a.cpp", - "src/module/b.cpp", - "src/tests/test.cpp", - "src/fuzz/pickle.cpp", - "src/leaks/probe.cpp", - "src/fuzz/corpus/pickle/seed", - ) - for relative_path in paths: - path = self.root / relative_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(relative_path, encoding="utf-8") - self.matrix = ObserverMatrix( - shared_inputs=("build/shared.props",), - projects=( - Project( - "module", - "build/projects/module.vcxproj", - ("Debug", "Release"), - ( - TranslationUnit("module", "src/module/a.cpp", "a"), - TranslationUnit("module", "src/module/b.cpp", "b"), - ), - ), - Project( - "tests", - "build/projects/tests.vcxproj", - ("Debug", "Release"), - (TranslationUnit("tests", "src/tests/test.cpp", "test"),), - ), - Project( - "fuzz-pickle", - "build/projects/fuzz-pickle.vcxproj", - ("Fuzz",), - (TranslationUnit("fuzz-pickle", "src/fuzz/pickle.cpp", "fuzz"),), - ), - Project( - "leak-probe", - "build/projects/leak-probe.vcxproj", - ("Release",), - (TranslationUnit("leak-probe", "src/leaks/probe.cpp", "probe"),), - ), - ), - test_configurations=("Debug", "Release"), - fuzz_targets=("pickle",), - fuzz_seed_inputs={"pickle": ("src/fuzz/corpus/pickle/seed",)}, - leak_modes=("operations", "lifecycle"), - leak_scenarios=("small-success", "malformed"), - ) - - def tearDown(self) -> None: - self.temporary_directory.cleanup() - - def test_matrix_expands_project_config_tu_and_scenario_axes(self) -> None: - graph = build_observer_microdag(self.matrix, renderer=direct_renderer) - nodes = {node.name: node for node in graph.nodes} - - builds = [name for name in nodes if name.startswith("build-")] - analyzes = [name for name in nodes if name.startswith("analyze-")] - normalizes = [name for name in nodes if name.startswith("normalize-")] - leak_cases = [name for name in nodes if name.startswith("leak-case-")] - self.assertEqual(6, len(builds)) - self.assertEqual(10, len(analyzes)) - self.assertEqual(10, len(normalizes)) - self.assertEqual(4, len(leak_cases)) - self.assertEqual( - tuple(sorted(name for name in normalizes if name.startswith("normalize-msvc-"))), - nodes["merge-msvc-sarif"].deps, - ) - self.assertEqual( - ("analysis-gate", "fuzz-gate", "leaks-gate", "run-tests-debug", "run-tests-release"), - nodes["verify"].deps, - ) - - def test_every_node_has_one_pool_and_exact_owned_inputs_outputs(self) -> None: - graph = build_observer_microdag(self.matrix, renderer=direct_renderer) - descriptors = graph.descriptors(self.root) - output_owners = { - output: node.name for node in graph.nodes for output in node.outputs - } - - self.assertEqual(set(descriptors), {node.name for node in graph.nodes}) - for node in graph.nodes: - self.assertTrue(node.pool) - self.assertIsInstance(node.pool, str) - self.assertTrue(node.outputs) - self.assertFalse(any("*" in path or "?" in path for path in node.inputs)) - for input_path in node.inputs: - owner = output_owners.get(input_path) - if owner is not None: - self.assertIn(owner, node.deps) - - def test_jinja_template_is_only_a_descriptor_emitter(self) -> None: - template = ( - Path(__file__).parents[1] / "ixdag" / "templates" / "node.json.j2" - ).read_text(encoding="utf-8") - - self.assertEqual("{{ descriptor | tojson }}", template.strip()) - - -if __name__ == "__main__": - unittest.main() diff --git a/build/tests/test_native_graph.py b/build/tests/test_native_graph.py deleted file mode 100644 index 275f2de..0000000 --- a/build/tests/test_native_graph.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import json -import unittest -from pathlib import Path - -from build import native_graph - - -WORKSPACE = Path(__file__).resolve().parents[2] - - -class NativeGraphManifestTests(unittest.TestCase): - def test_exact_project_and_translation_unit_manifest(self) -> None: - manifest = native_graph.load_manifest(WORKSPACE) - - self.assertEqual( - [project.name for project in manifest.projects], - [ - "renpy", - "rpgmaker", - "zanzarah", - "tests", - "fuzz-pickle", - "fuzz-renpy", - "fuzz-rpgmaker", - "fuzz-zanzarah", - "leak-probe", - ], - ) - self.assertEqual( - [len(project.translation_units) for project in manifest.projects], - [6, 4, 4, 15, 2, 5, 3, 3, 5], - ) - self.assertEqual(len(manifest.translation_units), 47) - self.assertEqual(len({unit.key for unit in manifest.translation_units}), 47) - - def test_manifest_rejects_a_project_outside_the_explicit_allowlist(self) -> None: - with self.assertRaisesRegex(native_graph.NativeGraphError, "unknown project"): - native_graph.load_manifest(WORKSPACE, project_names=("renpy", "not-a-project")) - - def test_manifest_paths_are_repository_relative_existing_cpp_files(self) -> None: - manifest = native_graph.load_manifest(WORKSPACE) - - for unit in manifest.translation_units: - self.assertFalse(unit.source.is_absolute()) - self.assertEqual(unit.source.suffix, ".cpp") - self.assertTrue((WORKSPACE / unit.source).is_file()) - self.assertNotIn("..", unit.source.parts) - - def test_item_definition_clcompile_is_not_a_translation_unit(self) -> None: - manifest = native_graph.load_manifest(WORKSPACE) - leak_probe = next(project for project in manifest.projects if project.name == "leak-probe") - - self.assertEqual( - [unit.source.as_posix() for unit in leak_probe.translation_units], - [ - "src/core/io/bounded_stream.cpp", - "src/tests/leaks/probe.cpp", - "src/core/compression/zlib_codec.cpp", - "src/tests/support/archive_fixtures.cpp", - "src/tests/support/zlib_fixture.cpp", - ], - ) - - -class NativeGraphExpansionTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.manifest = native_graph.load_manifest(WORKSPACE) - cls.nodes = native_graph.expand_x64_nodes(cls.manifest) - cls.by_name = {node["name"]: node for node in cls.nodes} - - def test_native_build_and_test_execution_are_separate_nodes(self) -> None: - build_nodes = [node for node in self.nodes if node["name"].startswith("build-")] - run_nodes = [node for node in self.nodes if node["name"].startswith("run-tests-")] - - self.assertEqual(len(build_nodes), 25) - self.assertEqual(len(run_nodes), 5) - for configuration in ("debug", "release", "coverage", "asan", "ubsan"): - runner = self.by_name[f"run-tests-{configuration}"] - self.assertEqual( - runner["deps"], - [ - f"build-{configuration}-renpy", - f"build-{configuration}-rpgmaker", - f"build-{configuration}-tests", - f"build-{configuration}-zanzarah", - ], - ) - self.assertEqual(runner["resources"], {"cpu": 1, "test-run": 1}) - - fuzz_builds = [node for node in build_nodes if node["name"].startswith("build-fuzz-")] - self.assertEqual(len(fuzz_builds), 4) - self.assertFalse(any(node["name"].startswith("run-fuzz-") for node in self.nodes)) - - def test_fuzz_build_names_have_one_exported_cross_graph_contract(self) -> None: - targets = ("pickle", "renpy", "rpgmaker", "zanzarah") - - self.assertEqual( - {native_graph.fuzz_build_node_name(target) for target in targets}, - { - node["name"] - for node in self.nodes - if node["name"].startswith("build-fuzz-") - }, - ) - with self.assertRaisesRegex(native_graph.NativeGraphError, "unknown fuzz target"): - native_graph.fuzz_build_node_name("unknown") - - def test_release_build_names_have_one_exported_cross_graph_contract(self) -> None: - expected = { - native_graph.build_project_node_name("Release", project) - for project in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") - } - - self.assertTrue(expected.issubset(self.by_name)) - with self.assertRaisesRegex(native_graph.NativeGraphError, "unsupported project build"): - native_graph.build_project_node_name("ASan", "leak-probe") - - def test_analysis_expands_to_47_msvc_and_47_tidy_units(self) -> None: - msvc_nodes = [node for node in self.nodes if node["name"].startswith("analyze-msvc-")] - tidy_nodes = [node for node in self.nodes if node["name"].startswith("analyze-tidy-")] - normalize_msvc = [node for node in self.nodes if node["name"].startswith("normalize-msvc-")] - normalize_tidy = [node for node in self.nodes if node["name"].startswith("normalize-tidy-")] - - self.assertEqual(len(msvc_nodes), 47) - self.assertEqual(len(tidy_nodes), 47) - self.assertEqual(len(normalize_msvc), 47) - self.assertEqual(len(normalize_tidy), 47) - self.assertEqual(len(self.by_name["merge-msvc-sarif"]["deps"]), 47) - self.assertEqual(len(self.by_name["merge-tidy-sarif"]["deps"]), 47) - self.assertEqual( - self.by_name["analysis-gate"]["deps"], - ["merge-msvc-sarif", "merge-tidy-sarif"], - ) - - def test_selected_file_tidy_uses_isolated_intdir_and_exact_source(self) -> None: - node = next( - node - for node in self.nodes - if node["name"].startswith("analyze-tidy-renpy-") - and any("src/core/io/bounded_stream.cpp" in argument for argument in node["argv"]) - ) - - self.assertIn("-SelectedFile", node["argv"]) - selected_index = node["argv"].index("-SelectedFile") + 1 - self.assertEqual(node["argv"][selected_index], "src/core/io/bounded_stream.cpp") - self.assertEqual(node["resources"], {"clang-tidy": 1, "cpu": 1}) - self.assertEqual(len(node["writes"]), 1) - self.assertTrue(node["writes"][0].startswith(".artifacts/analysis/clang-tidy/x64/renpy/")) - self.assertTrue(node["writes"][0].endswith("/")) - self.assertEqual( - node["outputs"], - [f"{node['writes'][0]}obj/renpy.ClangTidy.log"], - ) - - def test_selected_file_msvc_uses_isolated_intdir_and_exact_source(self) -> None: - node = next( - node - for node in self.nodes - if node["name"].startswith("analyze-msvc-renpy-") - and any("src/core/io/bounded_stream.cpp" in argument for argument in node["argv"]) - ) - - self.assertIn("-SelectedFile", node["argv"]) - selected_index = node["argv"].index("-SelectedFile") + 1 - self.assertEqual(node["argv"][selected_index], "src/core/io/bounded_stream.cpp") - self.assertEqual(node["resources"], {"cpu": 1, "memory-gib": 2, "msvc-analysis": 1}) - self.assertEqual(len(node["writes"]), 1) - self.assertTrue(node["writes"][0].startswith(".artifacts/analysis/msvc/x64/renpy/")) - self.assertTrue(node["writes"][0].endswith("/")) - - def test_sarif_merges_receive_exact_normalizer_outputs_without_globs(self) -> None: - for backend in ("msvc", "tidy"): - normalizers = [ - node - for node in self.nodes - if node["name"].startswith(f"normalize-{backend}-") - ] - expected = [node["outputs"][0] for node in normalizers] - merge = self.by_name[f"merge-{backend}-sarif"] - argument_index = merge["argv"].index("-InputPathsJson") + 1 - - self.assertEqual(merge["inputs"], expected) - self.assertEqual(json.loads(merge["argv"][argument_index]), expected) - self.assertFalse(any("*" in path or "?" in path for path in merge["inputs"])) - - def test_msvc_and_tidy_writes_are_disjoint_from_debug_build(self) -> None: - analysis_writes = { - write - for node in self.nodes - if node["name"].startswith(("analyze-msvc-", "analyze-tidy-")) - for write in node["writes"] - } - debug_writes = { - write - for node in self.nodes - if node["name"].startswith("build-debug-") - for write in node["writes"] - } - - self.assertTrue(analysis_writes) - self.assertTrue(debug_writes) - self.assertTrue(analysis_writes.isdisjoint(debug_writes)) - for node in self.nodes: - for write in node["writes"]: - self.assertTrue(write.startswith(".artifacts/"), (node["name"], write)) - - def test_normalized_records_use_weighted_resources_and_explicit_writes(self) -> None: - self.assertEqual(len(self.nodes), len(self.by_name)) - for node in self.nodes: - self.assertIsInstance(node["resources"], dict) - self.assertTrue(node["resources"]) - self.assertTrue(all(weight > 0 for weight in node["resources"].values())) - self.assertEqual(node["run_after"], []) - self.assertIsInstance(node["writes"], list) - self.assertIn("outputs", node) - self.assertIn("cacheable", node) - - -if __name__ == "__main__": - unittest.main() diff --git a/build/tests/test_verify_graph.py b/build/tests/test_verify_graph.py deleted file mode 100644 index fc562fc..0000000 --- a/build/tests/test_verify_graph.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from build import graph_driver, verify_graph - - -WORKSPACE = Path(__file__).resolve().parents[2] - - -class VerifyGraphComposerTests(unittest.TestCase): - def test_analysis_scope_is_schema_v2_and_runnable(self) -> None: - composition = verify_graph.compose_x64_graph(WORKSPACE, scope="analysis") - - self.assertTrue(composition.runnable) - self.assertEqual(composition.mapping["targets"], ["analysis-gate"]) - self.assertEqual(composition.mapping["failure_policy"], "continue") - graph = graph_driver.Graph.from_mapping( - composition.name, composition.mapping, WORKSPACE - ) - names = set(graph._order(graph.targets)) - self.assertIn("doctor", names) - self.assertIn("restore", names) - self.assertIn("source-checks", names) - self.assertIn("analysis-gate", names) - self.assertEqual(len([name for name in names if name.startswith("analyze-msvc-")]), 47) - self.assertEqual(len([name for name in names if name.startswith("analyze-tidy-")]), 47) - - def test_native_graph_argv_binds_to_public_graph_leaf_entrypoint(self) -> None: - composition = verify_graph.compose_x64_graph(WORKSPACE, scope="analysis") - nodes = {node["name"]: node for node in composition.mapping["nodes"]} - - for name, node in nodes.items(): - if name in {"doctor", "restore", "source-checks"}: - continue - argv = node["argv"] - self.assertEqual(argv[:5], ["pwsh", "-NoLogo", "-NoProfile", "-File", "build.ps1"]) - self.assertEqual(argv[5], "graph-leaf") - self.assertEqual(argv[6], "-GraphLeafAction") - - def test_dynamic_external_dependencies_are_exactly_satisfied_by_native_nodes(self) -> None: - composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") - - self.assertFalse(composition.runnable) - self.assertEqual(composition.unwired_sections, ("dynamic",)) - self.assertEqual( - composition.external_dependencies, - ( - "build-fuzz-pickle", - "build-fuzz-renpy", - "build-fuzz-rpgmaker", - "build-fuzz-zanzarah", - "build-release-leak-probe", - "build-release-renpy", - "build-release-rpgmaker", - "build-release-tests", - "build-release-zanzarah", - ), - ) - names = {node["name"] for node in composition.mapping["nodes"]} - self.assertTrue(set(composition.external_dependencies).issubset(names)) - - def test_full_scope_is_plan_only_and_covers_dynamic_terminal_nodes(self) -> None: - composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") - - self.assertFalse(composition.runnable) - self.assertEqual( - composition.mapping["targets"], - [ - "analysis-gate", - "fuzz-timed-x64-pickle", - "fuzz-timed-x64-renpy", - "fuzz-timed-x64-rpgmaker", - "fuzz-timed-x64-zanzarah", - "leaks-aggregate", - "package-evidence", - "run-tests-asan", - "run-tests-coverage", - "run-tests-debug", - "run-tests-release", - "run-tests-ubsan", - ], - ) - graph = graph_driver.Graph.from_mapping(composition.name, composition.mapping, WORKSPACE) - graph._validate_write_conflicts(graph._order(graph.targets)) - - def test_every_resource_claim_has_declared_capacity(self) -> None: - composition = verify_graph.compose_x64_graph(WORKSPACE, scope="full") - capacities = composition.mapping["resources"] - - self.assertEqual( - capacities, - { - "archive-io": 2, - "binskim": 1, - "clang-tidy": 4, - "cpu": 12, - "dumpbin": 2, - "fuzz-runtime": 4, - "fuzz-writer-x64-pickle": 1, - "fuzz-writer-x64-renpy": 1, - "fuzz-writer-x64-rpgmaker": 1, - "fuzz-writer-x64-zanzarah": 1, - "memory-gib": 16, - "msvc-analysis": 4, - "native-msbuild": 2, - "package-init": 1, - "runtime-smoke": 2, - "sarif": 4, - "test-run": 2, - "umdh-capture": 1, - "umdh-diff": 2, - "umdh-session": 1, - }, - ) - for node in composition.mapping["nodes"]: - for resource, demand in node["resources"].items(): - self.assertLessEqual(demand, capacities[resource], (node["name"], resource)) - - def test_unknown_scope_is_rejected(self) -> None: - with self.assertRaisesRegex(verify_graph.VerifyGraphError, "unknown scope"): - verify_graph.compose_x64_graph(WORKSPACE, scope="not-a-scope") - - -if __name__ == "__main__": - unittest.main() diff --git a/build/tests/verify-orchestration-contract.Tests.ps1 b/build/tests/verify-orchestration-contract.Tests.ps1 deleted file mode 100644 index 1aa35af..0000000 --- a/build/tests/verify-orchestration-contract.Tests.ps1 +++ /dev/null @@ -1,98 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Read-PowerShellAst { - param([Parameter(Mandatory)][string] $Path) - - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) - if ($errors.Count -ne 0) { - $messages = @($errors | ForEach-Object Message) - throw "PowerShell parse failed for '$Path': $($messages -join '; ')" - } - return $ast -} - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$entrypointPath = Join-Path $repositoryRoot 'build\build.ps1' -$orchestrationFiles = @( - Get-Item -LiteralPath $entrypointPath - Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'build\lib') -File -Filter '*.ps1' | Sort-Object Name -) -$verifyFunctions = @( - foreach ($file in $orchestrationFiles) { - $ast = Read-PowerShellAst -Path $file.FullName - foreach ($definition in $ast.FindAll( - { - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq 'Invoke-Verify' - }, - $false - )) { - [pscustomobject]@{ File = $file.FullName; Definition = $definition } - } - } -) -if ($verifyFunctions.Count -ne 1) { - throw 'The build orchestration sources must define exactly one Invoke-Verify orchestrator.' -} - -$verifyText = $verifyFunctions[0].Definition.Extent.Text -foreach ($requiredFragment in @( - 'Get-CurrentVerifyHostArchitecture', - 'Get-VerifyRoutingPlan', - '$plan.RequestedArchitectures', - '$plan.Builds', - '$plan.TestRuns', - '$plan.SpecialistGates', - '$plan.PackageContentArchitectures', - '$plan.Deferred', - 'Invoke-Restore', - 'Invoke-Lint', - 'Invoke-Build', - 'Invoke-TestExecutable', - 'Invoke-CodeAnalysis', - 'Invoke-Coverage', - 'Invoke-ASan', - 'Invoke-Ubsan', - 'Invoke-LeakTest', - 'Invoke-Fuzz', - "-TargetName 'all'", - 'Invoke-Package' - )) { - if (-not $verifyText.Contains($requiredFragment, [StringComparison]::Ordinal)) { - throw "Invoke-Verify is missing the required plan-driven step: $requiredFragment" - } -} - -if ($verifyText -match "@\(\s*'(x86|x64|arm64)'" -or - $verifyText -match "@\(\s*'(Debug|Release)'") { - throw 'Invoke-Verify must consume the routing plan instead of declaring an architecture/configuration matrix.' -} -if ($verifyText.Contains('$PSScriptRoot', [StringComparison]::Ordinal)) { - throw 'Invoke-Verify must use the caller-provided build context instead of its physical source location.' -} - -$entrypointText = Get-Content -Raw -LiteralPath $entrypointPath -$verifyDispatch = [regex]::Match( - $entrypointText, - "(?s)'verify'\s*\{(?.*?)\r?\n\s*\}\r?\n\s*'clean'" -) -if (-not $verifyDispatch.Success) { - throw 'The verify command dispatch block is missing.' -} -$dispatchBody = $verifyDispatch.Groups['body'].Value -if (-not $dispatchBody.Contains('Invoke-Verify', [StringComparison]::Ordinal)) { - throw 'The verify command must delegate to Invoke-Verify.' -} -foreach ($forbiddenCall in @('Invoke-Lint', 'Invoke-Test ', 'Invoke-Audit')) { - if ($dispatchBody.Contains($forbiddenCall, [StringComparison]::Ordinal)) { - throw "The verify command duplicates orchestration outside the routing-plan consumer: $forbiddenCall" - } -} - -Write-Host '[OK] Verify orchestration is complete and consumes the routing plan without a duplicate matrix.' diff --git a/build/tests/verify-routing.Tests.ps1 b/build/tests/verify-routing.Tests.ps1 deleted file mode 100644 index 47a94ae..0000000 --- a/build/tests/verify-routing.Tests.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$routingModule = Join-Path $repositoryRoot 'build\lib\verify-routing.ps1' -if (-not (Test-Path -LiteralPath $routingModule -PathType Leaf)) { - throw 'Verify routing planner is missing.' -} -. $routingModule - -function Assert-SetEqual { - param( - [Parameter(Mandatory)][string[]] $Actual, - [Parameter(Mandatory)][string[]] $Expected, - [Parameter(Mandatory)][string] $Description - ) - - if (@(Compare-Object -ReferenceObject $Expected -DifferenceObject $Actual).Count -ne 0) { - throw "$Description differs from the expected routing contract." - } -} - -$x64Plan = Get-VerifyRoutingPlan -HostArchitecture x64 -RequestedArchitectures @('x86', 'x64', 'arm64') -Assert-SetEqual -Description 'Requested architectures' -Actual @($x64Plan.RequestedArchitectures) -Expected @( - 'x86', 'x64', 'arm64' -) -Assert-SetEqual -Description 'Build matrix' -Actual @($x64Plan.Builds | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( - 'x86:Debug', 'x86:Release', 'x64:Debug', 'x64:Release', 'arm64:Debug', 'arm64:Release' -) -Assert-SetEqual -Description 'Runnable deterministic tests' -Actual @($x64Plan.TestRuns | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( - 'x86:Debug', 'x86:Release', 'x64:Debug', 'x64:Release' -) -Assert-SetEqual -Description 'Specialist gates' -Actual @($x64Plan.SpecialistGates | ForEach-Object { "$($_.Name):$($_.Architecture)" }) -Expected @( - 'coverage:x64', 'asan:x86', 'asan:x64', 'ubsan:x64', 'leaks:x64', 'formats:x64' -) -Assert-SetEqual -Description 'Package content checks' -Actual @($x64Plan.PackageContentArchitectures) -Expected @( - 'x86', 'x64', 'arm64' -) -Assert-SetEqual -Description 'Package runtime checks' -Actual @($x64Plan.PackageRuntimeArchitectures) -Expected @( - 'x86', 'x64' -) - -$deferredKeys = @($x64Plan.Deferred | ForEach-Object { "$($_.Gate):$($_.Architecture)" }) -Assert-SetEqual -Description 'Deferred native work' -Actual $deferredKeys -Expected @('tests:arm64', 'package-runtime:arm64') -if (@($x64Plan.Deferred | Where-Object { [string]::IsNullOrWhiteSpace($_.Reason) }).Count -ne 0) { - throw 'Every deferred verify action must include a reason.' -} - -$currentHost = Get-CurrentVerifyHostArchitecture -if ($currentHost -notin @('x86', 'x64', 'arm64')) { - throw "The current verify host architecture is unsupported: $currentHost" -} - -$arm64Plan = Get-VerifyRoutingPlan -HostArchitecture arm64 -RequestedArchitectures @('arm64') -Assert-SetEqual -Description 'Native ARM64 tests' -Actual @($arm64Plan.TestRuns | ForEach-Object { "$($_.Architecture):$($_.Configuration)" }) -Expected @( - 'arm64:Debug', 'arm64:Release' -) -Assert-SetEqual -Description 'Native ARM64 package smoke' -Actual @($arm64Plan.PackageRuntimeArchitectures) -Expected @('arm64') -if ($arm64Plan.Deferred.Count -ne 0) { - throw 'A native ARM64 host must not defer requested ARM64 runtime checks.' -} - -Write-Host '[OK] Verify routing preserves build coverage and reports non-native runtime work explicitly.' diff --git a/build/verify_graph.py b/build/verify_graph.py deleted file mode 100644 index fc89f74..0000000 --- a/build/verify_graph.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -"""Compose and optionally execute the typed x64 verification micro-DAG.""" - -from __future__ import annotations - -import argparse -from dataclasses import dataclass -import json -from pathlib import Path -import sys -from typing import Mapping - -from build import dynamic_graph, graph_driver, native_graph - - -ANALYSIS_SCOPE = "analysis" -FULL_SCOPE = "full" -SCOPES = (ANALYSIS_SCOPE, FULL_SCOPE) - -RESOURCE_CAPACITIES = { - "archive-io": 2, - "binskim": 1, - "clang-tidy": 4, - "cpu": 12, - "dumpbin": 2, - "fuzz-runtime": 4, - "fuzz-writer-x64-pickle": 1, - "fuzz-writer-x64-renpy": 1, - "fuzz-writer-x64-rpgmaker": 1, - "fuzz-writer-x64-zanzarah": 1, - "memory-gib": 16, - "msvc-analysis": 4, - "native-msbuild": 2, - "package-init": 1, - "runtime-smoke": 2, - "sarif": 4, - "test-run": 2, - "umdh-capture": 1, - "umdh-diff": 2, - "umdh-session": 1, -} - -FULL_TARGETS = ( - "analysis-gate", - "fuzz-timed-x64-pickle", - "fuzz-timed-x64-renpy", - "fuzz-timed-x64-rpgmaker", - "fuzz-timed-x64-zanzarah", - "leaks-aggregate", - "package-evidence", - "run-tests-asan", - "run-tests-coverage", - "run-tests-debug", - "run-tests-release", - "run-tests-ubsan", -) - - -class VerifyGraphError(ValueError): - """Raised when typed graph sections cannot be composed without ambiguity.""" - - -@dataclass(frozen=True) -class GraphComposition: - name: str - scope: str - mapping: Mapping[str, object] - runnable: bool - unwired_sections: tuple[str, ...] - external_dependencies: tuple[str, ...] - - -def _leaf_argv(command: str, *arguments: str) -> list[str]: - return [ - "pwsh", - "-NoLogo", - "-NoProfile", - "-File", - "build.ps1", - command, - *arguments, - ] - - -def _root_nodes() -> list[dict[str, object]]: - common_inputs = [ - "build.ps1", - "build/**/*.ps1", - "build/**/*.props", - "build/**/*.proj", - "build/**/*.vcxproj", - "vcpkg.json", - ] - return [ - { - "name": "doctor", - "deps": [], - "run_after": [], - "resources": {"cpu": 1}, - "argv": _leaf_argv("doctor"), - "inputs": common_inputs, - "writes": [], - "outputs": [], - "fingerprint": ["contract=verify-root-v1", "node=doctor"], - "cacheable": False, - }, - { - "name": "restore", - "deps": ["doctor"], - "run_after": [], - "resources": {"cpu": 2, "memory-gib": 2}, - "argv": _leaf_argv("restore", "-Arch", "x64", "-RestoreFlavor", "all"), - "inputs": [ - *common_inputs, - "vcpkg-configuration.json", - "build/vcpkg/triplets/*.cmake", - ], - "writes": [".artifacts/vcpkg_installed"], - "outputs": [], - "fingerprint": ["contract=verify-root-v1", "node=restore", "flavors=default+asan"], - "cacheable": False, - }, - { - "name": "source-checks", - "deps": ["restore"], - "run_after": [], - "resources": {"cpu": 4, "memory-gib": 4}, - "argv": _leaf_argv( - "source-checks", "-Arch", "x64", "-SkipDependencyRestore" - ), - "inputs": [".clang-format", ".clang-tidy", *common_inputs, "src/**/*.cpp", "src/**/*.h"], - "writes": [ - ".artifacts/reports/cppcheck", - ".artifacts/reports/psscriptanalyzer", - ], - "outputs": [], - "fingerprint": ["contract=verify-root-v1", "node=source-checks"], - "cacheable": False, - }, - ] - - -def _validate_unique_nodes(nodes: list[dict[str, object]]) -> set[str]: - names = [node.get("name") for node in nodes] - if any(not isinstance(name, str) or not name for name in names): - raise VerifyGraphError("every composed node requires a non-empty string name") - unique = set(names) - if len(unique) != len(names): - duplicates = sorted(name for name in unique if names.count(name) > 1) - raise VerifyGraphError(f"composed graph contains duplicate nodes: {duplicates}") - return unique - - -def _validate_resources(nodes: list[dict[str, object]]) -> None: - for node in nodes: - claims = node.get("resources") - if not isinstance(claims, dict) or not claims: - raise VerifyGraphError(f"node has no resource claims: {node.get('name')}") - for resource, demand in claims.items(): - capacity = RESOURCE_CAPACITIES.get(resource) - if capacity is None: - raise VerifyGraphError( - f"undeclared resource {resource!r} in {node.get('name')}" - ) - if isinstance(demand, bool) or not isinstance(demand, int) or not 1 <= demand <= capacity: - raise VerifyGraphError( - f"invalid demand for {node.get('name')}/{resource}: {demand!r}" - ) - - -def compose_x64_graph(workspace: Path, *, scope: str, run_id: str = "local") -> GraphComposition: - """Compose normalized schema-v2 records and expose only honestly runnable scopes.""" - - if scope not in SCOPES: - raise VerifyGraphError(f"unknown scope: {scope!r}") - root = Path(workspace).resolve() - manifest = native_graph.load_manifest(root) - native_nodes = native_graph.expand_x64_nodes(manifest) - nodes = [*_root_nodes(), *native_nodes] - external_dependencies: tuple[str, ...] = () - - if scope == FULL_SCOPE: - dynamic = dynamic_graph.build_dynamic_topology( - architectures=("x64",), - host_architecture="x64", - leak_windows=3, - run_id=run_id, - ) - external_dependencies = tuple(dynamic["external_nodes"]) - native_names = _validate_unique_nodes(nodes) - missing = sorted(set(external_dependencies) - native_names) - if missing: - raise VerifyGraphError( - f"dynamic graph has unsatisfied external dependencies: {missing}" - ) - nodes.extend(dynamic["nodes"]) - - names = _validate_unique_nodes(nodes) - targets = ["analysis-gate"] if scope == ANALYSIS_SCOPE else list(FULL_TARGETS) - unknown_targets = sorted(set(targets) - names) - if unknown_targets: - raise VerifyGraphError(f"scope has unknown targets: {unknown_targets}") - _validate_resources(nodes) - - mapping: dict[str, object] = { - "resources": dict(RESOURCE_CAPACITIES), - "failure_policy": "continue", - "targets": targets, - "nodes": nodes, - } - # Let the production runner validate dependencies, paths, cycles, and write ownership. - graph = graph_driver.Graph.from_mapping(f"observer-x64-{scope}", mapping, root) - order = graph._order(graph.targets) - graph._validate_write_conflicts(order) - runnable = scope == ANALYSIS_SCOPE - unwired = () if runnable else ("dynamic",) - return GraphComposition( - name=f"observer-x64-{scope}", - scope=scope, - mapping=mapping, - runnable=runnable, - unwired_sections=unwired, - external_dependencies=external_dependencies, - ) - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subcommands = parser.add_subparsers(dest="command", required=True) - for command in ("plan", "run"): - child = subcommands.add_parser(command) - child.add_argument("--scope", choices=SCOPES, default=ANALYSIS_SCOPE) - child.add_argument( - "--workspace", type=Path, default=Path(__file__).resolve().parent.parent - ) - child.add_argument("--run-id", default="local") - subcommands.choices["plan"].add_argument("--json", action="store_true") - subcommands.choices["run"].add_argument("--jobs", type=int) - return parser - - -def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv) - try: - composition = compose_x64_graph( - args.workspace, scope=args.scope, run_id=args.run_id - ) - graph = graph_driver.Graph.from_mapping( - composition.name, composition.mapping, args.workspace - ) - if args.command == "plan": - plan = graph.plan() - if args.json: - print(graph_driver.plan_as_json(plan), end="") - else: - status = "runnable" if composition.runnable else "plan-only" - print(f"scope={composition.scope} status={status} nodes={len(plan)}") - for index, item in enumerate(plan, 1): - print(f"{index:03d} {item.node.name}") - return 0 - if not composition.runnable: - sections = ", ".join(composition.unwired_sections) - raise VerifyGraphError( - f"scope {composition.scope!r} is plan-only; unwired sections: {sections}" - ) - base = graph.workspace / ".artifacts" / "graph" / graph.name - summary = graph_driver.GraphRunner( - graph, base / "logs", base / "state", max_workers=args.jobs - ).run() - for item in summary.plan: - result = summary.results[item.node.name] - print( - f"{result.status:9} {result.name} {result.duration_seconds:.3f}s " - f"log={result.log_path}" - ) - print(f"total {summary.duration_seconds:.3f}s") - return 0 if summary.succeeded else 1 - except (VerifyGraphError, graph_driver.GraphValidationError) as error: - print(f"verify graph error: {error}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 5c770e6..0000000 --- a/docs/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Project documentation - -- [Current build-system handoff](current-status.md) — latest verified status, constraints, open work, and the exact - continuation order for a new chat. -- [Current code deep dive](code-deep-dive.md) — architecture, format implementations, defects, and technical risks found during the static review. -- [Build system and engineering workflow](build-system.md) — MSBuild, toolchain, tests, analysis, coverage, fuzzing, and packaging. -- [IX-derived local build DAG](ix-build-adaptation.md) — approved Python/Jinja orchestration, MD5 CAS, Windows - execution safety, and WSL2 extension plan. -- [High-assurance software methodology](critical-software-methodology.md) — TDD, architecture and ABI boundaries, ownership, parser safety, and verification policy. -- [Autonomous build-system work log](autonomous-work-log.md) — temporary decisions, doubts, and verification evidence for review. -- [GARbro module plan](garbro.md) -- [QuickBMS module plan](quickbms.md) -- [Unity module plan](unity.md) - -The repository-level [`README.md`](../README.md) remains the public project entry point. Agent instructions remain in [`AGENTS.md`](../AGENTS.md). diff --git a/docs/autonomous-work-log.md b/docs/autonomous-work-log.md deleted file mode 100644 index 751ed20..0000000 --- a/docs/autonomous-work-log.md +++ /dev/null @@ -1,96 +0,0 @@ -# Autonomous build-system work log - -This file records decisions, assumptions, unresolved questions, and verification evidence from the autonomous work -session started on 2026-08-01. It is intentionally a review log rather than permanent user documentation; discuss and -either accept, revise, or remove its entries after review. - -## Fixed requirements - -- The shipped modules are native MSVC binaries for x86, x64, and ARM64. -- Release code and dependencies use the static runtime (`/MT`) and ship without redistributable or third-party DLLs. -- The repository build graph uses MSBuild directly; vcpkg ports may use CMake internally. -- The public build entry point works from a clean Windows console without an IDE. -- Production code must reach 100% source line and branch coverage. -- Every meaningful parser surface must have deterministic regression tests and coverage-guided fuzzing. -- Real module DLLs require process-wide leak testing; bounded peak memory is tested separately from leak freedom. -- No local software may be installed or updated during this session. - -## Decisions made during autonomous work - -- Owner decision: keep MSBuild as the native compile/link backend. A Python DAG may orchestrate existing build and - verification leaves, but replacing MSBuild is currently too costly and risky relative to the expected benefit. - Revisit only with build-only evidence of a material native critical-path problem. -- The project registry baseline now follows the installed Scoop vcpkg revision - `9e593bb18ea69cc5095e012465dcd675a822ed0d` (2026-07-29). Of the direct dependencies, only Catch2 changed at this - baseline, from 3.15.2#1 to 3.15.3; zlib 1.3.2, nlohmann/json 3.12.0, and xxHash 0.8.3 remain current. -- Direct use of the zlib C API is isolated in `src/core/compression/zlib_codec.cpp`; all format and test code uses the - repository C++ boundary. The unused zstr adapter was removed after its exception type proved incompatible with the - current Windows sanitizer runtime. Keeping zlib 1.3.2 behind the boundary makes a future backend swap local. -- Parser allocation limits are now explicit: an encoded archive path is capped at 4,092 bytes, derived from the public - 1,024 UTF-16-code-unit Observer ABI buffer, and Zanzarah metadata is capped at 100,000 entries. The external corpus - currently peaks at 2,205 entries, so the entry limit leaves substantial compatibility headroom. Revisit the 100,000 - value if a legitimate corpus example exceeds it; do not remove the bound. - -- After the mandatory build, coverage, fuzzing, and leak gates are complete, parser-core extraction and IWYU - integration are authorized as stretch work during the same autonomous session. -- Parser-core extraction must preserve the public Observer ABI and self-contained per-module release layout; it is a - source boundary statically linked into the existing DLLs, not a new runtime binary. -- Owner decision: after the current build migration and portable parser-core extraction are complete, establish a - first-class WSL2/Linux developer workflow. It should run Mull and the useful Linux sanitizer/tooling surface against - the portable core while the release artifacts continue to be built and verified with MSVC on Windows. -- Missing IWYU is not permission to install software. In that case the repository/CI integration may be prepared, but - the unavailable local execution must be reported explicitly. -- Pickle memo entries currently use deep-copy value semantics because the parser's `unique_ptr` value model cannot - represent Python object identity or cycles. This is correct for the acyclic Ren'Py indexes in scope, but the parser - must not be described as a general cyclic Pickle implementation. -- Fixed-size Observer ABI metadata is always NUL-terminated and safely truncated with `wcsncpy_s(..., _TRUNCATE)`. - Overlong descriptive metadata no longer rejects an otherwise valid archive. -- Two ineffective defensive catches were removed: the `GetItem` runtime-error catch could not be reached after the - explicit out-of-range path, and zstr did not report a beyond-EOF seek through the attempted `ios_base::failure` - catch. Actual parser/read failures remain mapped at the ABI boundary. - -## Doubts and items for morning review - -- Current BinSkim output is clean at error level but emits BA2027 for all three modules because their PDBs do not embed - SourceLink metadata. Adding SourceLink would improve post-release debugging, but doing it correctly requires - commit-specific URL mapping and a decision about publishing source-linked PDBs; it is not being silently suppressed. -- `vcpkg.json` declares `LGPL-3.0-or-later`, while the repository root license and README identify the overall project - as GPLv3. This predates the build-system work. The manifest value may be inaccurate, but changing licensing metadata - needs an explicit owner decision rather than an autonomous guess. -- One ABI coverage test deliberately uses a host progress callback that throws `std::runtime_error` so the DLL's - best-effort exception mapping is exercised. Throwing a C++ exception through a DLL callback is not a supported host - contract, especially with independently linked `/MT` CRT instances. Decide whether to document callbacks as - non-throwing and keep this defensive catch, or introduce an internal failure seam so the branch is tested without a - cross-boundary exception. -- The large `M:\observer\_test` corpus was inspected read-only for representative format variants, but the required - hermetic suite does not execute its multi-gigabyte contents. Generated fixtures cover the supported RPA 2.0, RPA 3.0, - RGSS3A, and Zanzarah PAK variants; the real corpus remains an opt-in compatibility/stress run. -- Native ARM64 CI uses the hosted `windows-11-arm` label. The current environment discovery intentionally invokes the - amd64 MSBuild host and cross compiler, which should run under Windows ARM64 x64 emulation; the first hosted run is the - final proof of that runner/toolchain assumption. -- There is no separate `RelWithDebInfo` configuration. `Release` is the single shippable configuration and combines - `/O2`, `/GL`/`/LTCG`, `/MT`, compiler PDB information, and an explicitly full linker PDB. Symbols are archived - separately per architecture and do not add a runtime or distribution dependency to module DLLs. -- The repository now has a proposed critical-software-inspired policy in `docs/critical-software-methodology.md`. - It makes TDD, 100% first-party line/branch coverage, clean dependency direction, strict C/C++ ABI containment, - RAII-only ownership, bounded untrusted-input processing, and evidence from exact release DLLs mandatory. It does not - claim formal certification. -- Owner decision: decision tables are executable data-driven tests rather than manually maintained review documents; - critical compound conditions require targeted MC/DC reasoning in addition to the global 100% branch gate. -- Owner decision: mutation testing is mandatory. A surviving non-equivalent reached mutant is a test defect; there is - no accepted sub-100 mutation score. The engine remains an implementation decision because Mull requires LLVM and - has no supported native-Windows workflow; mutating the future portable parser core in Linux CI is the leading design. -- Owner decision: certification and coding-standard compliance are out of scope. Core Guidelines, CERT, and JPL ideas - are engineering inputs only; no MISRA or formal safety-standard profile will be maintained. -- Owner decision: do not impose an arbitrary maximum archive-file size. The read-only golden corpus includes RPA up to - 4,468,455,116 bytes, RGSS3A up to 901,714,312 bytes, and Zanzarah PAK up to 774,465,076 bytes. Defensive limits apply - instead to declared fields versus actual remaining input, ABI-representable paths, entry/allocation arithmetic, and - configurable expanded metadata/index budgets. Concrete metadata-budget defaults remain to be derived and tested. - -## Verification ledger - -Commands and their final results will be recorded here after the implementation stabilizes. - -- LLVM production coverage: 969/969 lines, 296/296 branches, 480/480 regions, and 79/79 functions (100% each). -- Deterministic suite at that point: 24 test cases and 539 assertions; x86/x64 MSVC Debug, x64 MSVC ASan, and the x64 - clang-cl coverage gate passed. diff --git a/docs/build-system.md b/docs/build-system.md index 6918c00..03e5937 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -2,8 +2,8 @@ ## Status -This document records the agreed target design. The MSBuild migration is implemented alongside it; coverage growth and -additional fuzz targets remain ongoing engineering work. +This document describes the implemented local build. The repository uses a Python/Jinja content-addressed DAG for +orchestration and keeps MSBuild as the native compile/link backend. ## Non-negotiable release contract @@ -21,10 +21,10 @@ additional fuzz targets remain ongoing engineering work. build.ps1 / build.cmd stable command-line entry point | v -build/build.ps1 environment discovery and task orchestration +tools/build/main.py command contract and graph composition | v -build/ObserverModules.proj aggregate MSBuild targets +tools/build/graphs/* fine-grained content-addressed DAG | v build/projects/*.vcxproj compile/link graph @@ -33,10 +33,26 @@ build/projects/*.vcxproj compile/link graph cl.exe / link.exe / lib.exe / rc.exe ``` -PowerShell is not the build engine. The root script is intentionally tiny and contains no source list, compiler flags, -or dependency graph. NMAKE was rejected because it would require hand-maintaining the platform/configuration matrix, -header dependency tracking, vcpkg integration, and project graph while still needing another tool for testing, -coverage, binary auditing, and packaging. +The five-line root PowerShell script only enters the exact-pinned `uv` environment and forwards arguments. Python +builds and executes the outer DAG; short inherited Jinja templates render tool recipes. MSBuild still owns native +project evaluation, compilation, linking, and C++ header dependencies. Replacing that mature backend would duplicate +substantial toolchain behavior without a demonstrated critical-path benefit. + +### DAG and content-addressed storage + +The outer engine adapts the small model used by [pg83/ix](https://github.com/pg83/ix): a node declares `in_dir`, +`out_dir`, dependencies, data, one resource pool, and a Jinja recipe. Templates use inheritance and `StrictUndefined`; +PowerShell-specific quoting is centralized instead of repeated in every leaf. + +Canonical MD5 covers the fully rendered recipe, descriptor/argv data, declared input bytes and paths, dependency UIDs, +toolchain/platform identity, and the executor schema. MD5 is a fast local content identity, not a cryptographic trust +boundary for remote artifacts. A CAS hit requires both the expected entry and its `touch` marker; failed or cancelled +work cannot publish the marker. + +`filelock` coordinates node publication and clean operations between cooperating local processes. Mutable paths are +confined below the exact repository output root and existing reparse points are rejected. `psutil` launches and +observes processes; a Windows Job Object terminates the complete descendant tree on failure or cancellation. These are +explicit local-build guarantees, not a claim of hostile-process isolation. ## Supported commands @@ -55,30 +71,30 @@ coverage, binary auditing, and packaging. .\build.ps1 audit-binaries -Arch x86,x64,arm64 .\build.ps1 package -Arch x86,x64,arm64 .\build.ps1 verify -Arch x64 +.\build.ps1 clean -CleanMode stale-work ``` -`build.cmd` is a convenience shim for `cmd.exe`; both entry points execute the same PowerShell implementation. +`build.cmd` is a convenience shim for `cmd.exe`; both entry points execute the same Python driver through the root +PowerShell launcher. Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every format target. -The IX-derived replacement is intentionally separate until it reaches complete command parity. Its current 533-node -analysis graph runs MSVC `/analyze` and clang-tidy independently per supported project/TU/architecture, normalizes each -report independently, and then executes deterministic per-architecture SARIF merge and semantic clean gates. From the -repository root, run the pinned environment without syncing or downloading: - -```powershell -uv run --project tools/build --frozen --no-sync python tools/build/main.py analysis-slice --repository . --arch all -``` - -Results are printed as exact paths below `out/cas`; a second identical run is served from touch-marker cache entries. -This pilot does not replace any documented `build.ps1` command yet. +The implementation is derived from IX's small recipe/DAG/CAS model. A node identity is canonical MD5 over its rendered +recipe, declared inputs, toolchain/configuration data, and dependency identities. A hit requires the immutable CAS +entry and its `touch` marker. Results are printed as exact paths below `out/cas`; mutable intermediates and locks live +below `out/work`. `verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A -non-native runtime check is reported explicitly as deferred and must be completed on its native CI runner; it is not -reported as executed locally. The verify fuzz phase always covers all four format targets; `-FuzzSeconds` controls its -bounded duration. +non-native runtime check is reported explicitly as deferred rather than falsely reported as executed. All gates are +designed to be runnable locally; the later WSL2 backend will supply Linux-only sanitizer and mutation capabilities. +The verify fuzz work covers all four format targets; `-FuzzSeconds` controls each bounded run. + +All graph families are merged into one executor. Ready nodes from builds, tests, analyzers, coverage, sanitizers, +fuzzing, leak checks, audit, and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the +shared global capacity; narrower named pools additionally protect tools such as UMDH and BinSkim without adding fake +phase-wide edges. ## Configurations @@ -99,17 +115,17 @@ copied into release packages. Library dependencies are declared by `vcpkg.json` in manifest mode. Repository-owned overlay triplets explicitly set both `VCPKG_CRT_LINKAGE` and `VCPKG_LIBRARY_LINKAGE` to `static` for all three architectures. The vcpkg baseline is -pinned in source control and packages are restored into architecture-specific directories below -`.artifacts/vcpkg_installed`; global vcpkg integration is not required. Separate install roots keep manifest-mode vcpkg -from pruning another architecture while switching targets. +pinned in source control and packages are restored into architecture/flavor-specific CAS nodes; global vcpkg +integration is not required. Separate install roots keep manifest-mode vcpkg from pruning another architecture while +switching targets. -Normal public commands restore their required dependencies by default. The experimental DAG uses one serial -`restore -RestoreFlavor all` before its parallel fork; `all` prepares both default and ASan dependency flavors (and -skips the unsupported ARM64 ASan flavor). Its later leaf commands pass `-SkipDependencyRestore`, which is reserved for -orchestration that has already completed that prerequisite. Calling `restore` itself with the skip switch is rejected. +Normal public commands include the exact restore nodes they require. Independent architecture/flavor restores may run +concurrently; ARM64 ASan is omitted because that configuration is unsupported. `-SkipDependencyRestore` remains a +deprecated compatibility no-op and is rejected on `restore` itself. Developer tools are not library dependencies and are discovered by `doctor`: +- `uv` and the repository environment initialized once with `uv sync --project tools/build --frozen`; - Visual Studio Build Tools 2022 with MSVC x86/x64 and ARM64 tools plus a Windows SDK; - PowerShell 7.4 or newer; - vcpkg; @@ -117,6 +133,9 @@ Developer tools are not library dependencies and are discovered by `doctor`: - Cppcheck; - PSScriptAnalyzer for PowerShell sources. +Normal `build.ps1` commands use `uv --frozen --no-sync`: they neither resolve nor download Python packages while a +build is running. `tools/build/uv.lock` exact-pins Python 3.14.6 and the small runtime dependency set. + ## Compiler and static-analysis policy Normal compilation uses `/W4 /WX /permissive-`, the conforming preprocessor, correct `__cplusplus`, SDL checks, and @@ -137,20 +156,23 @@ Header self-containment checks and clang-tidy's include diagnostics come first. Cppcheck suppressions must be narrow and documented. Repository-wide suppression of a diagnostic is not acceptable. -### CI analysis evidence +### Analysis evidence -Analysis gates and report publication are deliberately separate. CI lets each analyzer finish, retains its report even -when the gate fails, and only then enforces the analyzer exit status. Cppcheck emits one SARIF file per architecture. +Analysis work and the semantic gate are deliberately separate. Each analyzer may finish and preserve its report before +the deterministic merge/gate enforces findings. Cppcheck emits one SARIF file per architecture. MSVC `/analyze` keeps one raw SARIF file per first-party project and assigns every run a stable `msvc-analyze///` identity before the architecture directory is uploaded. The clang-tidy logs produced by that same compile-only graph are converted into a deduplicated first-party SARIF file with the stable identity `clang-tidy//`. -CodeQL uploads through its native action and retains both raw and post-processed SARIF as workflow artifacts. BinSkim -emits and uploads one release-binary SARIF file per architecture. Cppcheck, MSVC, clang-tidy, CodeQL, and BinSkim use -distinct code-scanning categories, so a later upload cannot replace another engine or architecture. Third-party SARIF -uploads are skipped for untrusted fork pull requests where the workflow token cannot write security events; their -reports are still archived as ordinary workflow evidence. +BinSkim emits one release-binary SARIF file per architecture. Reports remain separate by analyzer and architecture and +are stored as local CAS evidence. A CI job may repeat these commands, but it must not be the only way to execute or +inspect any mandatory gate. + +The main GitHub workflow is therefore a thin client: it provisions an otherwise empty hosted runner, then invokes the +same public `doctor` and bounded `verify` commands used locally. It contains no private gate graph, report parser, +artifact-path protocol, or release logic. The temporary mutation workflow remains separate only until its work moves +to the supported local WSL2 backend. ### Future analysis backlog @@ -172,7 +194,7 @@ architecture is being stabilized: out-of-range access; whole-project model checking is not a goal. PC-lint Plus has been considered and explicitly rejected for this project. Do not add it to the required toolchain or -CI matrix. +local verification matrix. ## Tests @@ -230,7 +252,7 @@ must pass under MSVC Debug before their clang-cl coverage result is accepted. ## Sanitizers and fuzzing The primary ASan path links production core code into a test executable. A dedicated loader smoke test also exercises -the actual instrumented module DLL inside a controlled host process; real FAR/Observer is not a CI dependency. +the actual instrumented module DLL inside a controlled host process; real FAR/Observer is not a test dependency. Windows ASan does not detect memory leaks, and a debug-CRT leak check in the test executable cannot account for all allocations made by separately linked shipping module DLLs. Leak testing is therefore a separate required layer rather @@ -263,15 +285,31 @@ lifecycle. Leak freedom and bounded memory consumption are different requirements. A separate memory-budget stress test should measure peak private bytes while processing synthetic large/sparse archives and verify that archive contents are not buffered wholesale. The external real-world corpus remains useful as an opt-in local compatibility and stress layer, -but the required CI leak gate must use small, repository-owned fixtures and finish deterministically. +but the required local leak gate uses small, repository-owned fixtures and finishes deterministically. Fuzzers are standalone executables. They never fuzz through the FAR process. The initial target is the Ren'Py Pickle -parser; archive-index, path, and decompression fuzzers are added as parsing is separated from filesystem I/O. Pull -requests replay every checked-in seed before running each of the four format targets for 30 seconds. The weekly -Saturday 18:17 UTC schedule runs Pickle, Ren'Py, RPG Maker, and Zanzarah for 30 minutes per target (approximately two -hours of coverage-guided execution after the build). Checked-in seeds include minimized regression inputs and compact -representative structures derived from the opt-in external corpus; the external archives themselves are not committed. -Every crash is minimized and committed as a deterministic regression input after triage. +parser; archive-index, path, and decompression fuzzers are added as parsing is separated from filesystem I/O. The local +gate replays every checked-in seed and then runs Pickle, Ren'Py, RPG Maker, and Zanzarah independently. Checked-in seeds +include minimized regression inputs and compact representative structures derived from the opt-in external corpus; +the external archives themselves are not committed. Every crash is minimized and committed as a deterministic +regression input after triage. + +## Output and cleanup + +`out/cas` contains immutable successful node results. `out/work` contains in-flight scratch space, failed-run evidence, +and `.locks`. Successful node scratch is removed immediately. Each active execution holds a run lease; `clean` takes a +coordination lock and refuses to race active runs or node publishers. + +`clean -CleanMode stale-work` removes only inactive scratch. `clean -CleanMode all` removes CAS entries, completed run +data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate that every target +is the exact repository `out` layout and reject reparse points or unknown entries. + +## Planned WSL2 backend + +After the portable parser core exists, the same Python graph/signing model will gain short POSIX-shell recipes and run +natively inside WSL2. Windows must not launch one `wsl.exe` per leaf. Platform, shell, and toolchain identity are signed, +so Linux and Windows outputs cannot alias. This local backend will add Mull mutation testing and the Linux +ASan/UBSan/LSan surface; shipping modules remain Windows/MSVC artifacts. ## Release audit and packaging diff --git a/docs/code-deep-dive.md b/docs/code-deep-dive.md deleted file mode 100644 index e16755d..0000000 --- a/docs/code-deep-dive.md +++ /dev/null @@ -1,197 +0,0 @@ -# Current Code Deep Dive - -Static review of the current ObserverModules implementation. The project was not built and the tests were not run during this review. - -## Scope - -The review covered: - -- the Observer ABI declarations and exported entry points; -- the common archive wrapper and extraction lifecycle; -- the Ren'Py, RPG Maker, and Zanzarah format implementations; -- the custom Ren'Py Pickle parser; -- the test harness and all current test cases; -- the history of the main format-related changes. - -## Architecture - -```text -FAR Manager - -> Observer - -> renpy.so - -> rpgmaker.so - -> zanzarah.so - -> dll.cpp Observer C ABI and HANDLE lifecycle - -> archive.cpp shared listing and extraction pipeline - -> format.cpp format-specific parser and decryption -``` - -There are two distinct plugin boundaries: - -1. Observer dynamically loads each Windows DLL and obtains its function table through `LoadSubModule()`. -2. Inside a module, the archive format is selected statically at link time. Each CMake target links the common `dll.cpp` and `archive.cpp` with one implementation of the non-virtual `extractor::extractor` methods. - -Despite its name, `extractor::extractor` is not a runtime-polymorphic format interface. Its destructor is virtual, but `get_archive_info()`, `list_files()`, and `decrypt()` are not. This distinction matters when evolving the internal module architecture. - -### Storage lifecycle - -1. `OpenStorage()` validates the supplied signature when present, opens an `ifstream`, and returns an `archive::archive*` cast to `HANDLE`. -2. `PrepareFiles()` parses and caches the archive index. -3. `GetItem()` exposes paths and sizes to Observer. -4. `ExtractItem()` copies a selected entry in 128 KiB chunks and invokes the progress callback. -5. `CloseStorage()` reconstructs a `unique_ptr` from the `HANDLE` and destroys the archive. - -The common archive layer is a useful separation: format implementations provide indexing and block transformation while file I/O and the Observer-facing lifecycle remain shared. - -## Format implementations - -### Ren'Py - -- Recognizes the `RPA-` signature and versions 2.0 and 3.0. -- Reads the compressed index at the offset stored in the archive header. -- RPA-3 offsets and lengths are decoded with the header key. -- Decompresses the index through the repository C++ compression boundary backed by statically linked zlib. -- Parses the index with the local Pickle subset. -- Supports the optional per-entry prefix/header and excludes its length from the body copied from the archive. - -The implementation only accepts one tuple segment per archive path. A valid index containing multiple segments reaches the explicit `Not implemented` branch in [`renpy.cpp`](../src/modules/renpy/renpy.cpp#L101). - -### RPG Maker VX Ace - -- Recognizes `RGSSAD\0\3`. -- Decodes index fields and file names using the archive magic. -- Stores a separate initial magic value for each file. -- Decrypts file bodies as a rolling 32-bit XOR stream. - -The shared extraction buffer is divisible by four, which is important for preserving the rolling-magic state between chunks. - -### Zanzarah - -- Recognizes four zero bytes. -- Reads the file count followed by path, relative offset, and block size records. -- Treats data offsets as relative to the end of the index. -- Skips the four-byte per-block attribute and removes one leading `..\` from entry paths. - -## Confirmed defects - -### 1. Cancellation is reported as success - -The progress adapter throws `archive::user_interrupt` when Observer requests cancellation in [`dll.cpp`](../src/dll.cpp#L110). However, [`archive::extract_file()`](../src/archive.cpp#L111) catches that exception and returns normally. - -Consequences: - -- the `SER_USERABORT` handler in `ExtractItem()` is unreachable for this path; -- Observer receives `SER_SUCCESS`; -- a partially extracted file remains at the destination. - -The exception should reach the ABI adapter, or cancellation should be returned explicitly through the internal API. - -### 2. Zanzarah uses vector capacity as the file count - -[`zanzarah.cpp`](../src/modules/zanzarah/zanzarah.cpp#L59) calls `reserve(file_count)` and then iterates until `files.capacity()`. - -The C++ standard only guarantees that capacity is at least the requested count. If an implementation allocates additional capacity, the parser reads records beyond the archive index. The decoded count must be stored separately and used as the loop bound. - -### 3. Small archives break the test harness - -The test adapter enables `failbit` exceptions, allocates a 128 KiB signature buffer, and unconditionally requests the entire buffer in [`observer.cpp`](../src/tests/framework/observer.cpp#L54). `ifstream::read()` sets `failbit` when a valid archive is shorter than 128 KiB, so `gcount()` is never used normally for such a file. - -The harness should read up to the available length without treating a short final read as a test failure. - -## Robustness and security risks - -### Untrusted lengths and offsets - -The parsers use archive-controlled values for allocations and stream positions without consistently checking them against the physical file size: - -- Zanzarah file count, `path_len`, block offset, and block size; -- RPG Maker `name_len`, file offset, and file size; -- Ren'Py index offset, decoded entry offset and size, and decompressed index size. - -A malformed archive can therefore cause excessive allocation, negative logical sizes, out-of-range seeks, or exceptions outside the intended format-specific error mapping. A bounded binary reader with checked arithmetic would remove most of this duplicated risk. - -### ABI pointer and structure validation - -The exported functions do not fully validate their C ABI inputs: - -- `OpenStorage()` checks `storage` but not `params.FilePath` or `info`; -- `GetItem()` does not check `item_info`; -- `ExtractItem()` assumes `Callbacks.FileProgress` is non-null; -- `LoadSubModule()` does not check its pointer or `StructSize` and is declared `noexcept`; -- the `StructSize` members supplied by Observer are otherwise ignored. - -Invalid host input can produce an access violation instead of a defined Observer error result. - -### Missing signature data disables format validation - -[`archive::open()`](../src/archive.cpp#L28) verifies the signature only when the `Data` span is non-empty. With empty signature data, any readable file reaches the format-specific `get_archive_info()`, which currently returns constant metadata. - -Whether this is observable in production depends on Observer's exact two-stage open protocol. The test harness explicitly notes that it does not yet reproduce that protocol. - -### Entry path handling - -Archive paths are mostly passed through unchanged except for slash replacement. Zanzarah removes only one leading `..\`; the other formats do not normalize traversal components. - -The final impact depends on how Observer constructs `DestPath`, because the module receives the destination rather than joining it itself. Nevertheless, the trust boundary should be made explicit and entry paths should be validated before exposure. - -### Exceptions at the ABI boundary - -The exported functions catch common `runtime_error` and `logic_error` cases, but allocation failures and some invalid-input failures are not covered consistently. C++ exceptions must not be allowed to escape into the Observer ABI. - -Header writes are also outside the body-write error mapping in `archive::extract_file()`, so an output failure while writing an entry header is reported as a generic system error rather than `SER_ERROR_WRITE`. - -## Ren'Py Pickle compatibility debt - -The local parser is deliberately a subset rather than a general Pickle implementation. Important limitations include: - -- `BINPUT`, `LONG_BINPUT`, and `MEMOIZE` store null placeholders; -- `BINGET` and `LONG_BINGET` push `None` instead of the memoized value; -- several declared opcodes have no implementation; -- `BINFLOAT` reads the payload as little-endian even though the Pickle opcode uses big-endian representation; -- `LONG1` values longer than eight bytes can overflow the signed 64-bit accumulator; -- frame sizes and protocol versions are read but not validated. - -The current real-world corpus evidently stays inside the supported subset, but support for all valid RPA-2.0/RPA-3.0 indexes should not be assumed. - -## Extraction semantics - -- Progress is reported for body chunks but not for an optional Ren'Py entry header, even though the header contributes to the size exposed by `GetItem()`. -- Partial output is not cleaned up after cancellation or read failure. -- One archive object owns one mutable `ifstream`; concurrent extraction through the same storage handle would race on `seekg()` and `read()`. -- An empty archive is reparsed on each `PrepareFiles()` call because an empty `files_` vector is also used as the "not prepared" state. - -These may be acceptable under current Observer call patterns, but those assumptions are not encoded in the internal API. - -## Existing test coverage - -The repository contains 26 end-to-end golden tests: - -- 14 Ren'Py archives; -- 9 RPG Maker archives; -- 3 Zanzarah archives. - -Each test compares the listed path, extracted size, and XXH3 hash. Loading all three modules for every archive also checks that exactly one module accepts the signature. This is a strong regression suite for the known corpus. - -Current gaps: - -- malformed and truncated archives; -- boundary values for lengths, counts, offsets, and headers; -- cancellation and progress accounting; -- invalid ABI pointers and structure sizes; -- short archives below 128 KiB; -- multi-segment Ren'Py entries and Pickle memo references; -- concurrency assumptions; -- the real Observer two-stage `OpenStorage()` sequence. - -The corpus and expected listings live outside the repository under `M:\observer\_test`, so the tests are not self-contained. - -## Suggested order for later remediation - -1. Fix cancellation propagation and Zanzarah's capacity loop. -2. Introduce checked/bounded binary reads and validate entry ranges against the archive size. -3. Harden every exported ABI entry point and guarantee that no exception crosses it. -4. Decide whether to complete the Pickle subset or replace it with a narrower parser designed specifically for Ren'Py indexes. -5. Add focused unit and negative tests alongside the existing real-archive golden tests. -6. Define path-normalization, partial-output, progress, and concurrency contracts explicitly. - -Build-system and development-workflow redesign are intentionally outside the scope of this document and can be addressed separately. diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md index 29b84bd..d4dd57b 100644 --- a/docs/critical-software-methodology.md +++ b/docs/critical-software-methodology.md @@ -98,7 +98,7 @@ documentation. At minimum it answers: Required tests include valid minimal and representative archives, every error category, zero/one/maximum boundaries, one-past-limit cases, truncation at meaningful byte positions, arithmetic edges, callback failures, cancellation, and resource cleanup after every failure path. Multi-gigabyte external corpora remain optional compatibility/stress input; -small generated repository fixtures are the deterministic CI contract. +small generated repository fixtures are the deterministic local contract. ## Verification ladder @@ -109,11 +109,12 @@ Every layer finds a different defect class; passing one does not substitute for that reach each branch. 3. **Mutation testing:** mutate first-party parser/application logic and require every non-equivalent reached mutant to be killed. Surviving mutants are fixed with stronger behavioral tests; they are not hidden by lowering a percentage - threshold. Mutation reports are retained as CI evidence. + threshold. Mutation reports are retained as local evidence. 4. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on x86, x64, and ARM64 where the runner is native. -5. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, CodeQL, and PowerShell - analysis. Diagnostics are fixed or narrowly justified, never globally muted. -6. **Dynamic analysis:** MSVC AddressSanitizer locally/CI where supported; clang-cl AddressSanitizer and +5. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis. + Diagnostics are fixed or narrowly justified, never globally muted. Additional services such as CodeQL may repeat or + extend this evidence but cannot replace a local gate. +6. **Dynamic analysis:** MSVC AddressSanitizer where supported; clang-cl AddressSanitizer and UndefinedBehaviorSanitizer as independent diagnostic builds; UMDH across repeated real-DLL operations and repeated load/unload cycles. Peak private bytes and resource budgets are checked separately from leak growth. 7. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated @@ -136,9 +137,9 @@ The repository does not need bureaucratic documents for trivial refactors, but a the change exists, which failure it prevents, which test demonstrates it, which binary contains it, and whether it changes an ABI, parser limit, dependency, or accepted risk. -Security- and reliability-relevant deviations are recorded in `docs/autonomous-work-log.md` until accepted and moved to -a durable decision record. Analyzer suppressions include the rule, exact scope, rationale, and a test or other evidence -that covers the residual risk. +Security- and reliability-relevant deviations are recorded beside the affected code/configuration and in the owning +issue or commit. Analyzer suppressions include the rule, exact scope, rationale, and a test or other evidence that +covers the residual risk. ## Standards adapted, not claimed @@ -163,6 +164,7 @@ that covers the residual risk. with a local lifetime rationale? 4. **Mutation engine:** select and pin a practical engine. Mull is LLVM-based and produces machine-readable reports, but does not provide a supported native-Windows workflow; the current leading design is to mutate the portable - parser core under Clang in Linux CI while keeping all shipped binaries MSVC-built and independently tested. + parser core under Clang in the local WSL2 backend while keeping all shipped binaries MSVC-built and independently + tested. 5. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become mandatory release gates. diff --git a/docs/current-status.md b/docs/current-status.md deleted file mode 100644 index 653f99c..0000000 --- a/docs/current-status.md +++ /dev/null @@ -1,178 +0,0 @@ -# Current build-system handoff - -This is the working handoff for continuing the MSBuild/toolchain migration in a new Codex chat. It records the -repository state verified on 2026-08-01, what is implemented, and what remains. Treat concrete command evidence below -as authoritative; older counts in `autonomous-work-log.md` are historical snapshots. - -## Repository state - -- Repository: `C:\Users\Roma\Dev\ObserverModules` -- Branch: `codex/msbuild-toolchain` -- Checkpoint commit `294b584` preserves the complete MSBuild migration and DAG experiments before the approved - IX-derived replacement work. The branch has not been pushed. -- `CLAUDE.md` has been replaced by repository-level `AGENTS.md`. -- The old CMake/IDE entry points are being removed. CMake remains acceptable only inside vcpkg ports. -- Do not create a background Goal for this work. In the previous chat Goal cards repeatedly became unavailable. -- Do not install or update global/system tools. Project-local Python dependencies may be added and locked with `uv`; - report missing external developer tools instead of installing them. -- Keep verbose command output in `.artifacts/*.log` and report only concise results in chat; verbose dynamic-check - output repeatedly triggered a Codex UI display filter, although commands and filesystem changes continued normally. - -## Fixed engineering requirements - -- Native MSVC release modules for x86, x64, and ARM64. -- Static CRT (`/MT`) and static third-party dependencies; release packages must not require the VC redistributable or - adjacent dependency DLLs. -- The console entry point is `build.ps1`/`build.cmd`, with direct MSBuild project files under `build/`. -- `Release` is the shippable optimized-with-symbols configuration: `/O2`, `/GL`, `/LTCG`, `/MT`, compiler-embedded - `/Z7` symbols, and a full linker PDB. `/Z7` removes the shared compiler-PDB service from parallel isolated build - leaves; the linker still emits the distributable PDB. There is intentionally no separate `RelWithDebInfo`. -- TDD, 100% production line and branch coverage, mutation testing, deterministic tests, format-aware dynamic testing, - leak checks, binary inspection, and hermetic CI. -- Clean architecture and strict C/C++ boundaries. Application and test code must not call a C API directly; a C API is - contained in a dedicated C++ adapter. Owning manual allocation in C++ is forbidden; use RAII and smart pointers. -- No compliance or certification profile is planned. - -## Implemented system - -- Direct MSBuild graph and the console commands documented by `build.ps1 help`: - `doctor`, `restore`, `build`, `test`, `source-checks`, `compiler-analysis`, `test-coverage`, `test-asan`, - `test-ubsan`, `test-leaks`, `fuzz`, `audit-binaries`, `package`, `verify`, and `clean`. -- Static vcpkg triplets for x86/x64/ARM64 plus sanitizer-specific variants. The manifest baseline is - `9e593bb18ea69cc5095e012465dcd675a822ed0d`; current direct versions are Catch2 3.15.3, - nlohmann/json 3.12.0#2, xxHash 0.8.3, and zlib 1.3.2#1. -- clang-format, clang-tidy, Cppcheck, PSScriptAnalyzer, MSVC `/analyze`, ASan, clang-cl UBSan, LLVM coverage, libFuzzer, - UMDH scaffolding, dumpbin, BinSkim, and GitHub CodeQL workflow scaffolding. -- One combined PDB archive per architecture is implemented in the packaging source. -- Hermetic generated fixtures cover Ren'Py RPA 2.0/RPA 3.0, RPG Maker RGSS3A, and Zanzarah PAK. The multi-gigabyte - corpus at `M:\observer\_test` is optional compatibility/stress input, not a mandatory CI checkout. -- Format-aware executable targets exist for Pickle, Ren'Py, RPG Maker, and Zanzarah. `-FuzzTarget` can select one or - all targets. -- Resource bounds exist for ABI-representable paths, entry metadata, actual remaining input, and expanded Ren'Py - metadata. There is no arbitrary whole-archive size limit. -- zstr has been removed. Production zlib calls are contained in `src/core/compression/zlib_codec.cpp`; fixture - compression is separately contained in `src/tests/support/zlib_fixture.cpp`. Other production/test code sees only - C++ APIs, so a later decompressor replacement is local. -- Ren'Py, RPG Maker, and Zanzarah share `observer::io::bounded_stream` for checked positioning and exact reads. - -### IX-derived replacement pilot - -- `tools/build` now contains the working Python 3.14/Jinja replacement core: canonical MD5 identities, demand DAG - execution, named pools, native interprocess locks through `filelock`, repository-local `out/cas`, confined work - paths, and Windows process-tree cancellation through `psutil` plus `pywin32` Job Objects. -- The project environment exact-pins `coverage==7.15.2`; its blocking gate measures all first-party `core`, `graphs`, - and CLI code with 100% line and branch coverage and no production exclusions. -- The production analysis graph now covers every supported first-party project/TU occurrence on x86, x64, and ARM64. - It contains 533 nodes: 262 independent raw analyzer leaves, 262 independent normalizers, three shared restore leaves, - and per-architecture deterministic merge/semantic gates. The x64-only leak probe is deliberately absent from the - cross-architecture graphs. Every analyzer has its own object root. -- Raw TU identities currently use a deliberately restricted literal-include closure. Computed includes, - `#include_next`, and `__has_include` are rejected instead of being under-signed; the first-party path namespace is - also signed. The planned generic form is a two-phase compiler-derived resolver using MSVC `/sourceDependencies` and - `clang-scan-deps`, so this bootstrap scanner is not presented as a general C++ preprocessor. -- Official `vswhere.exe` and `VsDevCmd.bat` discovery fingerprints MSBuild 17.14.51.32402, MSVC 14.44.35207, - clang-tidy 19.1.5, Windows SDK 10.0.26100.0, and the resolved vcpkg root. Discovery does not mutate the parent - environment or install tools. The signed developer-environment delta deliberately excludes inherited/transport - `PATH`; runtime overlays append the host path case-insensitively, so CAS identities are stable across launch modes. -- The native graph splits project/configuration/architecture builds and Catch2 shards into isolated CAS leaves. - x64 Debug and Release matrices have run successfully with four concurrent MSBuild project leaves. Compiler debug - data uses `/Z7`, avoiding cross-Job `mspdbsrv` RPC failures while the linker still emits a full PDB. -- Fine-grained fuzz and release-binary audit graphs are implemented. Each fuzz target has independent build, corpus - replay, and nonce-signed timed execution; each module audit has three independent dumpbin leaves, a PE policy gate, - a BinSkim leaf, and a separate SARIF policy gate. -- The earlier 1,248-line pilot count is obsolete now that real graph families have replaced projections. Re-measure - production LOC after parity and the required structural reduction; compare the final replacement, not an incomplete - slice, with the 2,321-line old PowerShell production surface. -- The root `build.ps1` contract has deliberately not switched. Run the pilot from the repository root with: - - ```powershell - uv run --project tools/build --frozen --no-sync python tools/build/main.py analysis-slice --repository . --arch all - uv run --project tools/build --frozen --no-sync python tools/build/main.py native --repository . --arch x64 --config Debug - ``` - -## Latest verified evidence - -All commands below completed successfully after the latest Pickle regression fix. - -| Gate | Result | -| --- | --- | -| MSVC x64 Debug deterministic suite | 35 test cases, 639 assertions | -| LLVM production line coverage | 1126/1126, 100% | -| LLVM production branch coverage | 358/358, 100% | -| LLVM production functions | 91/91, 100% | -| Short Pickle format run | 151,692 executions in 15 seconds | -| Short Ren'Py format run | 345,037 executions in 15 seconds | -| Short RPG Maker format run | 219,383 executions in 15 seconds | -| Short Zanzarah format run | 418,998 executions in 15 seconds | -| Replacement Python suite before leak/package completion | 143 tests; 1305 statements and 380 branches, all 100% | -| Complete 191-node x64 graph, first cold run | 134 seconds; one real C26498 finding reached only the semantic gate | -| x64 incremental after the one-line `constexpr` fix | 17.7 seconds | -| Complete 191-node x64 graph, warm | 2.91 seconds | -| Cold addition of x86 and ARM64 | about 200 seconds for 168 new raw analyzer leaves at 12-way concurrency | -| Complete 533-node three-architecture graph, warm | 4.38 seconds | -| x64 Debug native graph, four project leaves and four test shards | cold run green; no C1090 after `/Z7` | -| x64 Release native graph, including leak-probe build | cold run green with `/MT`, `/GL`, `/LTCG`, and linker PDB | -| x64 Debug native graph, warm | 1.66 seconds | - -The short all-format command returned exit code 0. Detailed local evidence is in: - -- `.artifacts/regression-test.log` -- `.artifacts/coverage-check.log` -- `.artifacts/format-check.log` -- `.artifacts/coverage/x64/coverage.json` -- `.artifacts/coverage/x64/coverage.lcov` - -The dynamic Pickle run found an invalid mark-position transition after the first 100% coverage result. A minimized -unit regression now requires the parser to reject it as a normal parse error, `pop_to_mark()` validates its invariant, -and both 100% coverage and the all-format short run passed again. - -## Required next work, in order - -1. Complete the in-progress split of source checks, builds/tests, fuzz targets, leak scenarios/modes, audit work, - packaging, and remaining gates - into the smallest safe nodes with measured pool capacities. -2. Replace the restricted include-closure bootstrap with the recorded compiler-derived two-phase resolver before the - CAS is treated as generic for arbitrary future C++ include forms. -3. Preserve the current `build.ps1` implementation and old DAG experiments until the replacement has full result - parity and measured cold/warm local evidence. Only then switch the entry point. After the switch is verified and - checkpointed, delete the superseded PowerShell orchestration, experimental DAGs, their legacy-only tests, and stale - build documentation; retain the MSBuild projects/props/targets because they remain the native backend. -4. After the portable parser core is established, add the documented WSL2/Linux local backend for Mull and the Linux - sanitizer surface. CI repeats locally runnable commands; it is not the only place those gates may run. - -After the mandatory gates above, the authorized stretch work is IWYU integration and extraction of a portable parser -core statically linked into the existing module DLLs. This must not add a runtime DLL or change the public Observer ABI. -After that core boundary is established, add a supported WSL2/Linux developer workflow for mutation testing and the -Linux sanitizer/tooling surface that Windows cannot provide. The WSL2 build is an additional quality backend for the -portable core, not a replacement for the shipping MSVC/Windows DLL matrix. - -## Known decisions and open questions - -- MSBuild remains the native compile/link backend. The Python DAG pilot may replace outer verification orchestration - only; it must not grow into a direct `cl.exe`/`link.exe` driver. Reconsider a Ninja-backed native prototype only if - build-only measurements show at least a 15% critical-path opportunity or MSBuild no-op evaluation is both above two - seconds and above 25% of build-only time. -- Owner decision: the production DAG must expose the smallest safe independent work units instead of wrapping whole - `build.ps1` gates. This includes project/configuration builds, test shards, analyzer project/translation-unit work, - SARIF normalization and merge, fuzz targets, leak scenarios/modes, audit tools/modules, and package smoke units. - Resource pools and isolated write roots—not artificial phase-wide dependencies—must constrain parallelism. -- Keep zlib 1.3.2 for now behind the C++ adapter. Replacing it remains possible, but the removed zstr layer—not zlib - itself—was the source of the earlier incompatibility. -- The exact metadata budgets are safety controls, not whole-file limits. Revisit values using real corpus statistics, - but do not remove checked arithmetic and actual-input bounds. -- `vcpkg.json` says `LGPL-3.0-or-later`, while the repository README/license identify GPLv3. The owner must decide the - correct manifest metadata. -- A test currently exercises defensive handling of a host callback that throws across the DLL boundary. Decide whether - callbacks should instead be documented as non-throwing and tested through an internal seam. -- UBSan instruments first-party clang-cl code but not the MSVC-built vcpkg dependencies. This limitation must be stated - accurately in CI evidence. -- MSan/TSan are not mandatory for the current Windows plugin architecture. Reconsider TSan only if meaningful - concurrent parser code is introduced; use UMDH/ASan and bounded-memory tests for the current memory requirements. - Once the portable parser core and WSL2 workflow exist, evaluate Linux ASan/UBSan/LSan routinely and add MSan/TSan - only where their platform and program-model prerequisites make their results meaningful. - -## Locally available tools reported by the owner - -The owner installed Cppcheck, PSScriptAnalyzer, LLVM/clang tools, MSVC AddressSanitizer, Spectre libraries, and BinSkim -(on `%PATH%`). vcpkg is managed exclusively through Scoop. Do not replace or self-update this setup. IWYU and a local -CodeQL CLI have not been established; GitHub Actions may use the official CodeQL action without a local install. diff --git a/docs/garbro.md b/docs/garbro.md deleted file mode 100644 index 842defb..0000000 --- a/docs/garbro.md +++ /dev/null @@ -1,421 +0,0 @@ -# GARbro Observer Module — Implementation Plan - -## Architecture - -``` -dll.cpp (C, Observer API exports) - → garbro_archive.h/cpp (pure C++, orchestration) - → bridge.h/cpp (C++/CLI behind pimpl, talks to GARbro) - → GARbro.Core.dll (ILRepack-merged: GameRes + ArcFormats + all deps) -``` - -Only `bridge.cpp` compiles with `/clr`. All other files are pure native C++. - -### Distribution Layout - -``` -modules/ -├── garbro.so ← C++/CLI mixed-mode DLL (platform-specific: x86 or x64) -├── observer_user.ini ← generated filter list (all GARbro extensions) -└── garbro/ - ├── GARbro.Core.dll ← ILRepack-merged assembly (Any CPU, ~10-15 MB) - └── Formats.dat ← encryption schemes database -``` - -### Repository Layout - -``` -ObserverModules/ -├── extern/ -│ └── GARbro/ ← git submodule -├── tools/ -│ └── gen_ini/ ← C# console tool: generates observer_user.ini -│ ├── gen_ini.csproj -│ └── Program.cs -├── src/ -│ ├── api.h -│ ├── modules/ -│ │ └── garbro/ -│ │ ├── bridge.h ← pure C++ interface (pimpl) -│ │ ├── bridge.cpp ← C++/CLI implementation (/clr) -│ │ ├── garbro_archive.h ← pure C++ archive wrapper -│ │ ├── garbro_archive.cpp -│ │ ├── dll.cpp ← Observer API exports (pure C) -│ │ ├── garbro.def ← DLL exports -│ │ └── observer_user.ini ← template (overwritten by gen_ini) -│ └── tests/ -│ ├── garbro.cpp ← integration tests -│ └── framework/ -└── CMakeLists.txt -``` - -### CI Pipeline - -``` -Step 1: git submodule update --init (GARbro) -Step 2: nuget restore extern/GARbro -Step 3: msbuild extern/GARbro/GARbro.sln /p:Configuration=Release /p:Platform="Any CPU" -Step 4: ilrepack /out:GARbro.Core.dll GameRes.dll ArcFormats.dll ArcExtra.dll ArcLegacy.dll -Step 5: dotnet run --project tools/gen_ini (generates observer_user.ini) -Step 6: cmake --preset x64-release && cmake --build build/x64-release -Step 7: cmake --preset x86-release && cmake --build build/x86-release -Step 8: ctest (run all tests) -Step 9: cpack (package x86 + x64 ZIPs) -``` - ---- - -## Phase 0: Preparation - -### 0.1 — Interface Design (BLOCKING — all other phases depend on this) - -- [ ] **0.1.1** [PROG-A] Define `src/modules/garbro/bridge.h` — pure C++ interface with pimpl: - - `garbro::file_info` struct (path, size, packed_size) - - `garbro::archive` class (try_open, format, list_files, extract, file_count) - - `garbro::init(module_dir_path)` / `garbro::shutdown()` free functions - - init loads `GARbro.Core.dll` and `Formats.dat` from `garbro/` subfolder relative to module_dir_path - - No managed types leak outside -- [ ] **0.1.2** [REVIEW-A] Review `bridge.h` — check: no CLI types, no leaking .NET, pimpl correct, const-correctness, noexcept where appropriate -- [ ] **0.1.3** [PROG-A] Fix review findings for `bridge.h` - -### 0.2 — Build System Skeleton (can start after 0.1.1) - -- [ ] **0.2.1** [PROG-B] Add GARbro as git submodule at `extern/GARbro` -- [ ] **0.2.2** [PROG-B] Add `garbro` target to `CMakeLists.txt`: - - Shared library, output `garbro.so` - - `bridge.cpp` compiled with `/clr` and `/EHa` flags (per-file property) - - All files compiled with `/MD` (dynamic CRT, required by `/clr`) - - Other files compiled as native C++ - - Reference `GARbro.Core.dll` via `#using` - - Add `garbro.def` with `LoadSubModule` / `UnloadSubModule` exports -- [ ] **0.2.3** [PROG-B] Create `src/modules/garbro/garbro.def` -- [ ] **0.2.4** [PROG-B] Verify the skeleton compiles (empty stubs) -- [ ] **0.2.5** [REVIEW-B] Review CMake changes — check: /clr only on bridge.cpp, /MD not conflicting with other modules, correct assembly references, both x86 and x64 presets work -- [ ] **0.2.6** [PROG-B] Fix review findings for build system - -### 0.3 — GARbro Build + ILRepack (parallel with 0.2) - -- [ ] **0.3.1** [PROG-B] Create build script `scripts/build_garbro.bat`: - - `nuget restore extern/GARbro` - - `msbuild extern/GARbro/GARbro.sln /p:Configuration=Release /p:Platform="Any CPU"` - - `ilrepack /out:GARbro.Core.dll GameRes.dll ArcFormats.dll ArcExtra.dll ArcLegacy.dll ` - - Copy `GARbro.Core.dll` + `Formats.dat` to build output -- [ ] **0.3.2** [PROG-B] Verify ILRepack produces working `GARbro.Core.dll`: - - Load in test harness - - FormatCatalog.Instance initializes - - ArcFormats discovered -- [ ] **0.3.3** [REVIEW-B] Review build script — check: all deps included in ILRepack, Formats.dat copied, idempotent -- [ ] **0.3.4** [PROG-B] Fix review findings - -### 0.4 — Extension List Generator (parallel with 0.2, 0.3) - -- [ ] **0.4.1** [PROG-C] Create `tools/gen_ini/gen_ini.csproj` — .NET console app referencing `GARbro.Core.dll` -- [ ] **0.4.2** [PROG-C] Implement `tools/gen_ini/Program.cs`: - - Load `FormatCatalog.Instance` - - Load `Formats.dat` scheme - - Enumerate `catalog.ArcFormats.SelectMany(f => f.Extensions)` - - Deduplicate, sort, format as `*.ext` - - Output `observer_user.ini` with `[Modules]` and `[Filters]` sections -- [ ] **0.4.3** [PROG-C] Write tests for gen_ini: - - Output is valid INI format - - Contains `[Modules]` section with `GARbro=modules\garbro.so` - - Contains `[Filters]` section with comma-separated extensions - - No empty extensions in output - - No duplicate extensions -- [ ] **0.4.4** [PROG-C] Verify gen_ini runs after ILRepack and produces correct output -- [ ] **0.4.5** [REVIEW-C] Review gen_ini — check: handles empty extensions, deduplication, INI escaping -- [ ] **0.4.6** [PROG-C] Fix review findings - ---- - -## Phase 1: Bridge Layer (C++/CLI ↔ GARbro) - -All items in Phase 1 can run **in parallel** with Phase 2 (archive layer) once `bridge.h` is finalized. - -### 1.1 — Init/Shutdown - -- [ ] **1.1.1** [PROG-A] Write tests for `garbro::init()` / `garbro::shutdown()`: - - init loads FormatCatalog from `GARbro.Core.dll`, loads `Formats.dat` scheme - - double-init is safe (idempotent) - - shutdown after init doesn't crash - - shutdown without init doesn't crash -- [ ] **1.1.2** [PROG-A] Implement `garbro::init()` and `garbro::shutdown()` in `bridge.cpp`: - - Use `GetModuleFileName()` to find own DLL path - - Resolve `garbro/GARbro.Core.dll` and `garbro/Formats.dat` relative to it - - Load assembly, initialize FormatCatalog, deserialize scheme -- [ ] **1.1.3** [PROG-A] Verify tests pass, check coverage — must be 100% lines+branches -- [ ] **1.1.4** [REVIEW-A] Review init/shutdown — check: thread safety, resource leaks, exception handling across managed/native boundary, path resolution correct -- [ ] **1.1.5** [PROG-A] Fix review findings - -### 1.2 — Format Detection (try_open) - -- [ ] **1.2.1** [PROG-A] Write tests for `garbro::archive::try_open()`: - - Valid archive → returns non-null, format() returns correct tag - - Invalid file → returns nullptr - - Non-existent path → returns nullptr (no exception) - - Empty file → returns nullptr - - Test with at least 3 different archive formats from GARbro test data -- [ ] **1.2.2** [PROG-A] Implement `try_open()` in `bridge.cpp`: - - Create `ArcView` from path - - Call `ArcFile::TryOpen()` - - Store `ArcFile^` in pimpl via `gcroot<>` - - Return format tag via `format()` -- [ ] **1.2.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **1.2.4** [REVIEW-A] Review try_open — check: ArcView lifecycle, GCHandle pinning, wstring conversion correctness, exception translation -- [ ] **1.2.5** [PROG-A] Fix review findings - -### 1.3 — File Listing (list_files / file_count) - -- [ ] **1.3.1** [PROG-A] Write tests for `list_files()` and `file_count()`: - - Known archive → correct file count - - Known archive → correct file names, sizes, packed_sizes - - Archive with subdirectories → paths preserved with backslashes - - PackedEntry → packed_size != size - - Empty archive (0 files) → empty list -- [ ] **1.3.2** [PROG-A] Implement `list_files()` and `file_count()`: - - Iterate `ArcFile::Dir` - - Convert `Entry` / `PackedEntry` → `garbro::file_info` - - Handle path encoding (Shift-JIS / UTF-8) -- [ ] **1.3.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **1.3.4** [REVIEW-A] Review list_files — check: encoding conversion, PackedEntry detection, memory allocation -- [ ] **1.3.5** [PROG-A] Fix review findings - -### 1.4 — File Extraction (extract) - -- [ ] **1.4.1** [PROG-A] Write tests for `extract()`: - - Extract known file → output matches expected bytes (hash check) - - Extract compressed file → decompressed correctly - - Progress callback receives bytes - - Progress callback returning false → extraction aborts - - Extract to non-writable path → throws write_error - - Index out of range → throws -- [ ] **1.4.2** [PROG-A] Implement `extract()`: - - Call `ArcFile::OpenEntry()` to get Stream - - Read stream in 128KB chunks - - Write to dest path - - Call progress callback per chunk -- [ ] **1.4.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **1.4.4** [REVIEW-A] Review extract — check: stream disposal, large file handling, progress granularity, exception safety -- [ ] **1.4.5** [PROG-A] Fix review findings - -### 1.5 — Destructor / Resource Cleanup - -- [ ] **1.5.1** [PROG-A] Write tests: - - Destroy archive → no leaks (ArcView disposed) - - Destroy after partial extraction → clean shutdown - - Move semantics work correctly -- [ ] **1.5.2** [PROG-A] Implement destructor — dispose GCHandle, release ArcFile -- [ ] **1.5.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **1.5.4** [REVIEW-A] Review destructor — check: prevent double-free, prevent access after dispose -- [ ] **1.5.5** [PROG-A] Fix review findings - ---- - -## Phase 2: Archive Layer (pure C++) - -Can run **in parallel** with Phase 1 once `bridge.h` is finalized. -Uses mock/stub of bridge for unit testing. - -### 2.1 — Mock Bridge - -- [ ] **2.1.1** [PROG-B] Create `mock_bridge.h/cpp` — test double for `garbro::archive`: - - Configurable: set file list, set extract behavior, set format string - - Tracks calls: open count, extract calls, last progress callback -- [ ] **2.1.2** [REVIEW-B] Review mock — check: covers all bridge.h methods, configurable error injection -- [ ] **2.1.3** [PROG-B] Fix review findings - -### 2.2 — Archive Wrapper - -- [ ] **2.2.1** [PROG-B] Write tests for `garbro_archive` (new class, does NOT reuse extractor.h): - - `open()` → delegates to `garbro::archive::try_open()`, returns archive_info with format - - `open()` with invalid file → throws - - `prepare_files()` → populates file list from bridge - - `get_file()` → returns correct file_info by index - - `get_file()` out of range → throws out_of_range - - `extract_file()` → delegates to bridge extract with progress callback - - `extract_file()` user abort → throws user_interrupt - - Path separators normalized to backslash -- [ ] **2.2.2** [PROG-B] Implement `garbro_archive` in `src/modules/garbro/garbro_archive.h/cpp`: - - Wraps `garbro::archive` (from bridge.h) - - Adapts to same interface pattern as `archive::archive` but without extractor dependency - - Converts `garbro::file_info` to internal file struct -- [ ] **2.2.3** [PROG-B] Verify tests pass, 100% coverage -- [ ] **2.2.4** [REVIEW-B] Review archive wrapper — check: exception translation, callback wiring, no resource leaks -- [ ] **2.2.5** [PROG-B] Fix review findings - ---- - -## Phase 3: DLL Entry Points (Observer API) - -Depends on Phase 2 interface being stable. Can start writing tests while Phase 1+2 finish. - -### 3.1 — dll.cpp for GARbro Module - -- [ ] **3.1.1** [PROG-C] Write tests for `OpenStorage`: - - Valid archive → SOR_SUCCESS, storage handle set, StorageGeneralInfo populated - - Invalid file → SOR_INVALID_FILE - - Null storage pointer → SOR_INVALID_FILE - - Format field ≤ 32 wchars -- [ ] **3.1.2** [PROG-C] Write tests for `CloseStorage`: - - Close valid handle → no crash - - Close null handle → no crash -- [ ] **3.1.3** [PROG-C] Write tests for `PrepareFiles`: - - After open → TRUE - - Null handle → FALSE - - Double prepare → TRUE (idempotent) -- [ ] **3.1.4** [PROG-C] Write tests for `GetItem`: - - Valid index → GET_ITEM_OK, StorageItemInfo populated (path, size, packed_size) - - Index past end → GET_ITEM_NOMOREITEMS - - Negative index → GET_ITEM_ERROR - - Null handle → GET_ITEM_ERROR - - Path encoding correct (Japanese filenames → wchar_t) -- [ ] **3.1.5** [PROG-C] Write tests for `ExtractItem`: - - Valid extraction → SER_SUCCESS, file created at DestPath - - Read error → SER_ERROR_READ - - Write error → SER_ERROR_WRITE - - User abort (callback returns false) → SER_USERABORT - - Null handle → SER_ERROR_SYSTEM -- [ ] **3.1.6** [PROG-C] Write tests for `LoadSubModule` / `UnloadSubModule`: - - LoadSubModule fills ModuleId, ModuleVersion, ApiVersion, ApiFuncs - - All function pointers non-null - - UnloadSubModule doesn't crash -- [ ] **3.1.7** [PROG-C] Implement `src/modules/garbro/dll.cpp`: - - `LoadSubModule` → call `garbro::init(module_dir)`, fill params - - `UnloadSubModule` → call `garbro::shutdown()` - - `OpenStorage` → create garbro_archive, try open - - Other functions follow existing dll.cpp pattern -- [ ] **3.1.8** [PROG-C] Verify tests pass, 100% coverage -- [ ] **3.1.9** [REVIEW-C] Review dll.cpp — check: matches existing module pattern, handle lifecycle, exception safety at C boundary, no exceptions escape extern "C" -- [ ] **3.1.10** [PROG-C] Fix review findings - ---- - -## Phase 4: Integration Testing - -Depends on Phases 1, 2, 3 all complete. - -### 4.1 — End-to-End Tests via DLL Loading - -- [ ] **4.1.1** [PROG-D] Create test archives from at least 5 different GARbro-supported formats: - - KiriKiri XP3 - - AliceSoft ALD - - Ren'Py RPA (overlap with existing module — verify both work) - - RPG Maker (overlap with existing module — verify both work) - - One more format (e.g., NScripter NSA, Majiro ARC) -- [ ] **4.1.2** [PROG-D] Generate `.expected.json` baselines for each test archive (hash + size) -- [ ] **4.1.3** [PROG-D] Write `src/tests/garbro.cpp` — Catch2 test cases using existing `test::observer` framework: - - Load garbro.so module - - Open each test archive - - List files, verify count and names - - Extract all files, verify hashes match baseline -- [ ] **4.1.4** [PROG-D] Run integration tests, fix failures -- [ ] **4.1.5** [REVIEW-D] Review integration tests — check: deterministic, no hardcoded paths, cleanup temp files, covers error paths too -- [ ] **4.1.6** [PROG-D] Fix review findings - -### 4.2 — Coexistence Test - -- [ ] **4.2.1** [PROG-D] Test that garbro module and existing modules (renpy, rpgmaker) can load simultaneously -- [ ] **4.2.2** [PROG-D] Test that Observer tries garbro module when existing modules reject a file -- [ ] **4.2.3** [PROG-D] Test that existing modules take priority for their registered extensions (*.rpa, *.rgss3a) - ---- - -## Phase 5: Packaging - -Can start partially during Phase 4. - -### 5.1 — CPack Integration - -- [ ] **5.1.1** [PROG-B] Add CPack rules for garbro module: - - `garbro-{DATE}-{ARCH}-dll.zip` contents: - - `garbro.so` (platform-specific) - - `observer_user.ini` (generated by gen_ini) - - `garbro/GARbro.Core.dll` (Any CPU, ILRepack-merged) - - `garbro/Formats.dat` - - `licenses/` - - `garbro-{DATE}-{ARCH}-pdb.zip` with debug symbols - - Both x86 and x64 packages (same GARbro.Core.dll, different garbro.so) -- [ ] **5.1.2** [REVIEW-B] Review packaging — check: all runtime files included, folder structure correct, .NET Framework 4.7.2 noted as prerequisite -- [ ] **5.1.3** [PROG-B] Fix review findings - ---- - -## Phase 6: Final Review - -- [ ] **6.1** [REVIEW-ALL] Full code review of all files: - - Consistent style with existing ObserverModules code - - No memory leaks across managed/native boundary - - No exceptions escaping extern "C" functions - - All error paths tested - - 100% line + branch coverage confirmed -- [ ] **6.2** [PROG-ALL] Fix all final review findings -- [ ] **6.3** [REVIEW-ALL] Confirm zero open findings -- [ ] **6.4** Run full test suite (all modules including garbro), both x86 and x64 -- [ ] **6.5** Build release packages for x86 and x64 - ---- - -## Parallelism Map - -``` -Phase 0.1 (bridge.h interface) - │ - ├───────────────────────┬──────────────────┐ - ▼ ▼ ▼ -Phase 0.2 Phase 0.3 Phase 0.4 -(CMake skeleton) (GARbro build (gen_ini tool) -[PROG-B] + ILRepack) [PROG-C] - [PROG-B] - │ │ │ - ├───────────────────────┴──────────────────┘ - │ - ├──────────────────┬──────────────────┐ - ▼ ▼ ▼ -Phase 1 Phase 2 Phase 3.1.1-3.1.6 -(bridge impl) (archive layer) (dll.cpp tests) -[PROG-A] [PROG-B] [PROG-C] - │ │ │ - └──────────────────┴──────────────────┘ - │ - ▼ - Phase 3.1.7-3.1.10 - (dll.cpp impl) - │ - ▼ - Phase 4 - (integration) - [PROG-D] - │ - ▼ - Phase 5 - (packaging) - │ - ▼ - Phase 6 - (final review) -``` - -## Agent Roles - -| Role | Responsibility | -|------|---------------| -| **PROG-A** | Bridge layer (C++/CLI) — `bridge.h`, `bridge.cpp`, bridge tests | -| **PROG-B** | Build system + GARbro build + ILRepack + archive layer + mock + packaging | -| **PROG-C** | gen_ini tool + DLL entry points — `dll.cpp`, Observer API tests | -| **PROG-D** | Integration tests — end-to-end, coexistence, test data | -| **REVIEW-A** | Reviews PROG-A output | -| **REVIEW-B** | Reviews PROG-B output | -| **REVIEW-C** | Reviews PROG-C output | -| **REVIEW-D** | Reviews PROG-D output | -| **REVIEW-ALL** | Final cross-cutting review | - -## Technical Notes - -- `/clr` is incompatible with `/EHsc` — use `/EHa` for `bridge.cpp` -- `/clr` is incompatible with static CRT (`/MT`) — the entire garbro module must use `/MD` (dynamic CRT); this does NOT affect other modules (renpy, rpgmaker, zanzarah) which keep `/MT` -- `gcroot` is the correct way to store managed references in native classes (inside pimpl) -- GARbro uses `BinaryFormatter` for `Formats.dat` — requires .NET security settings in 4.7.2+ -- ILRepack NuGet: `dotnet tool install --global ILRepack` or download from https://github.com/gluck/il-repack -- `GARbro.Core.dll` is Any CPU — works in both x86 and x64 CLR contexts -- Test archives should be small (< 1MB each) and committed to the test data directory -- To update GARbro: `cd extern/GARbro && git pull && cd ../.. && git add extern/GARbro && git commit` diff --git a/docs/ix-build-adaptation.md b/docs/ix-build-adaptation.md deleted file mode 100644 index 13a7916..0000000 --- a/docs/ix-build-adaptation.md +++ /dev/null @@ -1,194 +0,0 @@ -# IX-derived local build DAG - -This document records the owner-approved target for replacing the large PowerShell -orchestration layer. The decision was finalized on 2026-08-01 after studying -[`pg83/ix`](https://github.com/pg83/ix) at revision -`66726a904152246fbef8b27e26e878840f6d7fb7`. Implementation follows strict TDD. - -## Scope - -MSBuild remains the native Windows compile/link backend. The new layer only renders -recipes, signs their complete inputs, constructs a fine-grained DAG, executes ready -nodes concurrently, and caches successful outputs. Replacing MSBuild with direct -`cl.exe`/`link.exe` orchestration is out of scope. - -The existing `build.ps1` command contract remains authoritative until the new system -has result parity for the complete local verification matrix. The coarse and fine DAG -experiments under `build/` are frozen fallback/oracle code, not the foundation of the -new implementation. - -Prefer a mature, focused open-source library or the Python standard library over -first-party infrastructure whenever it provides the required contract. Dependencies -are pinned exactly with `uv`; custom code is reserved for ObserverModules-specific -policy or guarantees unavailable from an existing component. - -## Kept from IX - -- Jinja recipe inheritance; -- `in_dir`/`out_dir` node semantics; -- content-addressed immutable outputs; -- MD5 identities and touch-marker cache hits; -- demand-driven `asyncio` graph traversal; -- exactly one named resource pool per node; -- dependency UID propagation into dependent identities. - -## Windows adaptations - -- Jinja renders PowerShell recipes instead of POSIX shell recipes. MSBuild, vcpkg, - clang-tidy, fuzzers, UMDH, audit tools, and packaging remain ordinary recipe tools. -- `StrictUndefined` makes a missing template value fail before signing or execution. -- PowerShell values use a dedicated single-quote filter; recipes do not interpolate - unquoted paths or user-controlled values. -- Windows Job Objects replace POSIX process groups so cancellation terminates the - complete `pwsh -> MSBuild -> cl/link` process tree. -- A per-UID interprocess lock prevents concurrent local processes from publishing the - same CAS entry. -- Every mutable path is confined below the repository output root. Escapes and existing - reparse-point components or leaves are rejected before mutation. -- The Linux isolation model is not claimed on Windows. Path confinement and Job Objects - provide narrower, explicit guarantees rather than pretending to reproduce `unshare`. - -The local concurrency model assumes cooperating build processes using the UID lock. It -does not claim protection from another same-user process maliciously replacing path -components during a filesystem operation; that stronger boundary would require -handle-relative Win32 filesystem operations or an OS sandbox. The driver therefore -validates confinement and existing reparse points before mutation and uses exclusive -creation, without duplicating speculative post-operation TOCTOU checks. - -MD5 is retained deliberately for compatibility with the IX content-identity model. It -identifies local deterministic build inputs; it is not presented as a cryptographic -integrity boundary for untrusted remote artifacts. - -## Repository layout - -```text -ObserverModules/ -|-- build.ps1 -|-- tools/ -| `-- build/ -| |-- pyproject.toml -| |-- uv.lock -| |-- main.py -| |-- core/ -| | |-- execute.py, graph.py, recipe.py, render.py, sign.py -| | |-- paths.py, runtime.py, store.py -| | |-- sarif.py, toolchain.py -| | `-- windows_job.py, windows_process.py -| |-- graphs/ -| | `-- analysis.py -| |-- templates/ -| | |-- base.json, script.json, argv.json -| | |-- pwsh.ps1, msbuild.ps1, analysis.ps1 -| | `-- msvc-analyze.ps1, clang-tidy.ps1, vcpkg.ps1 -| `-- tests/ -|-- out/ -| |-- cas/ -| | `-- -/ -| | |-- out/ -| | |-- log.txt -| | `-- touch -| `-- work/ -| |-- / -| `-- .locks/ -|-- src/ -`-- docs/ -``` - -Templates remain flat while the set is small. A maintained leaf recipe should normally -be 2-15 lines because setup, error handling, logging, and common argv live in inherited -base templates. Generated scripts may be longer; they are disposable execution data. -Subdirectories are introduced only when real template families require them. - -`out/` contains only two top-level entries and is ignored by Git: - -- `out/cas` contains UID-addressed outputs, their successful-task log, and a zero-byte - `touch` marker; -- `out/work` contains in-flight compiler intermediates, failed-task evidence, - quarantined incomplete entries, and internal lock files. A successful node removes - its exact scratch directory immediately; successful run directories therefore do - not accumulate beside the CAS. - -There is no speculative top-level `results` or `trash` directory. Commands print exact -result paths. An incomplete CAS entry has no marker, is never a cache hit, and is moved -under the locked work area or safely removed before rebuilding. - -## Rendering and identity - -A logical node selects a recipe and passes explicit validated values such as project, -source, architecture, configuration, tool paths, `in_dir`, and `out_dir`. Jinja expands -the complete inheritance chain before any process starts. - -As in IX, the rendered descriptor contains a literal `exec` argv array and signed -`data`. PowerShell uses a constant `-Command` wrapper that constructs a script block -from the exact UTF-8 recipe bytes received on stdin. Parser and terminating errors -therefore produce a nonzero process result; stdin remains recipe transport, not -interactive input. This avoids a temporary script pathname in the identity and -therefore avoids a UID/path cycle. Concrete CAS/work paths are derived after signing -and supplied through validated process environment values. - -The canonical MD5 input includes: - -- rendered recipe bytes; -- literal argv and relevant environment/configuration values; -- logical input paths and exact input bytes; -- dependency UIDs; -- toolchain and target-platform identity; -- the recipe/executor schema identity. - -Mapping order cannot affect the digest. A change to any signed field must change the -UID. A cache hit requires both the expected CAS entry and its touch marker. -The readable node-name suffix in `-` is diagnostic only, as in IX's -`-` layout; the MD5 is the content identity. - -## Graph and failure model - -Nodes are split at the smallest safe independently executable unit. The production -verify graph includes separate project/configuration builds, test shards, MSVC analysis -and clang-tidy translation units, SARIF normalization and fan-in, fuzz targets, leak -scenario/mode work, audit tool/module work, and package/smoke units. - -Every node names one pool. Initial pool classes are `full`, `slot`, `fuzz`, `umdh`, -`binskim`, and `misc`; measured resource behavior determines capacities. Pools constrain -actual contention instead of imposing phase-wide ordering edges. - -Infrastructure failure or cancellation produces no touch marker. Diagnostic tools may -write their raw report and an explicit status result even when findings exist; a later -semantic gate consumes those reports and fails the build after evidence is preserved. - -## WSL2 extension - -The Python graph/signing core is platform-neutral. A future WSL2 workflow adds a common -`sh.sh` base and short `.sh` counterparts for portable actions such as clang-tidy, -fuzzing, sanitizers, and Mull. PowerShell is not translated automatically into shell. - -The Linux driver runs once inside WSL2 and executes the DAG natively with POSIX process -groups. Windows must not launch one `wsl.exe` process per node. Platform, shell recipe, -and toolchain identity are signed, so Windows and Linux outputs cannot alias. The WSL2 -workflow is a local quality backend for the portable parser core; shipping DLLs remain -Windows/MSVC artifacts. - -## TDD migration order - -Steps 1-3 are proven and the analysis portion of step 4 is complete for every supported -project/TU/architecture occurrence. The same per-leaf shape remains -`/analyze || clang-tidy -> normalize || normalize -> merge -> semantic gate`; the complete -533-node three-architecture graph is a 4.38-second warm cache hit. Expansion remains -data-driven and does not switch the root entry point. - -1. Prove Jinja inheritance, `StrictUndefined`, PowerShell quoting, and canonical MD5. -2. Prove graph validation, demand execution, pool behavior, touch cache hits, confined - paths, interprocess locks, and Job Object cancellation with tool-free unit tests. -3. Implement one real x64 vertical slice for `src/modules/renpy/pickle.cpp`: - MSVC `/analyze` and clang-tidy in parallel, deterministic SARIF normalization, and a - final semantic gate. A second identical run must be all cache hits; changing an input - must invalidate only its consumers. -4. Expand to every first-party translation unit, then split fuzzing, leaks, audits, - packaging, and the remaining verification stages. The TU expansion is complete; the - other graph families are in progress. -5. Establish full result parity and benchmark cold/warm local runs against the current - `build.ps1 verify` baseline. -6. Switch the root entry point only after parity, then remove superseded graph and - PowerShell code. The checkpoint commit keeps that cleanup recoverable. - -No tool is installed or updated automatically. `uv` owns the pinned Python/Jinja -environment once the already-approved local prerequisite is available. diff --git a/docs/quickbms.md b/docs/quickbms.md deleted file mode 100644 index 7f0a230..0000000 --- a/docs/quickbms.md +++ /dev/null @@ -1,518 +0,0 @@ -# QuickBMS Observer Module — Implementation Plan - -## Architecture - -``` -dll.cpp (C, Observer API exports — same pattern as other modules) - → quickbms_archive.h/cpp (pure C++, orchestration + scripts.ini parsing) - → quickbms.lib (QuickBMS compiled as static library, pure C) -``` - -Everything is **native C/C++**. No .NET, no interop, no managed code. - -Does NOT use `extractor.h` — QuickBMS extraction is opaque (BMS script handles -everything internally via `dumpa()`), incompatible with chunk-based `decrypt()` model. - -### Distribution Layout - -``` -modules/ -├── quickbms.so ← native C/C++ DLL -├── observer_user.ini ← generated from scripts.ini (all mapped extensions) -└── quickbms/ - ├── scripts.ini ← extension → BMS script mapping (user-editable) - ├── scripts/ - │ ├── kirikiri_xp3.bms ← example scripts (bundled) - │ ├── unreal_pak.bms - │ ├── unity_assets.bms - │ ├── rpgmaker_vxace.bms - │ └── ... - └── docs/ - └── bms_syntax.md ← BMS scripting reference -``` - -### Repository Layout - -``` -ObserverModules/ -├── extern/ -│ └── quickbms/ ← git submodule or vendored source -│ ├── quickbms.c -│ ├── bms.c, cmd.c, perform.c, file.c, var.c, ... -│ ├── compression/ -│ ├── encryption/ -│ └── libs/ -├── tools/ -│ └── gen_quickbms_ini/ ← generates observer_user.ini from scripts.ini -├── src/ -│ ├── api.h -│ ├── modules/ -│ │ └── quickbms/ -│ │ ├── quickbms_archive.h ← pure C++ archive wrapper -│ │ ├── quickbms_archive.cpp -│ │ ├── quickbms_wrapper.h ← C++ wrapper around QuickBMS C internals -│ │ ├── quickbms_wrapper.cpp -│ │ ├── scripts_config.h ← scripts.ini parser -│ │ ├── scripts_config.cpp -│ │ ├── dll.cpp ← Observer API exports -│ │ ├── quickbms.def -│ │ └── observer_user.ini ← template -│ └── tests/ -│ ├── quickbms.cpp ← integration tests -│ ├── quickbms_wrapper_test.cpp ← wrapper unit tests -│ └── scripts_config_test.cpp ← config parser tests -└── CMakeLists.txt -``` - -### CI Pipeline - -``` -Step 1: git submodule update --init (quickbms) -Step 2: cmake --preset x64-release (builds quickbms.lib + quickbms.so) -Step 3: cmake --preset x86-release (builds quickbms.lib + quickbms.so) -Step 4: python/script gen observer_user.ini from scripts.ini -Step 5: ctest (run all tests) -Step 6: cpack (package x86 + x64 ZIPs) -``` - ---- - -## scripts.ini Format - -```ini -# Extension → BMS script mapping -# Multiple scripts separated by comma: tried in order until one succeeds -# Lines starting with # are comments - -[Scripts] -*.xp3 = kirikiri_xp3.bms -*.arc = arc_will.bms, arc_lzss.bms -*.pak = unreal_pak.bms, quake_pak.bms -*.dat = bgi_arc.bms, falcom_dat.bms -*.cpz = cmvs_cpz.bms -*.ypf = yumemiru_ypf.bms -*.nsa = nscripter_nsa.bms -*.wolf = wolfrpg.bms -``` - -No fallback — only explicitly mapped extensions are handled. -Observer `OpenStorage` returns `SOR_INVALID_FILE` for unmapped extensions. - ---- - -## Phase 0: Preparation - -### 0.1 — QuickBMS Static Library Build (BLOCKING) - -- [ ] **0.1.1** [PROG-A] Add QuickBMS source as git submodule at `extern/quickbms` -- [ ] **0.1.2** [PROG-A] Patch `file.c` — add progress callback hook into `dumpa()`: - - Add global function pointer: `int (*g_observer_progress_callback)(void *ctx, int64_t bytes) = NULL;` - - Add global context pointer: `void *g_observer_progress_context = NULL;` - - After each `write()` call inside `dumpa()`, invoke the callback: - ```c - if (g_observer_progress_callback) { - if (!g_observer_progress_callback(g_observer_progress_context, bytes_written)) { - return -1; // user abort - } - } - ``` - - This gives real-time progress updates and user abort support - - Keep patch minimal — only touch `dumpa()` write loop -- [ ] **0.1.3** [PROG-A] Create CMake target for `quickbms_lib` (static library): - - Compile all QuickBMS .c files except `main()` wrapper - - Define `QUICKBMS_AS_LIB` or similar preprocessor macro - - Handle the `#include`-based build: QuickBMS includes .c files from quickbms.c - - Static link all compression/encryption deps (zlib, lzma, etc. — already vendored in quickbms) - - Suppress warnings from QuickBMS code (`/W0` or pragma push/pop) - - Build for both x86 and x64 -- [ ] **0.1.4** [PROG-A] Verify `quickbms_lib` compiles cleanly on both architectures -- [ ] **0.1.5** [REVIEW-A] Review CMake integration and `dumpa()` patch — check: no symbol conflicts with other modules, all deps statically linked, no undefined symbols, patch is minimal and correct, abort path doesn't leak resources -- [ ] **0.1.6** [PROG-A] Fix review findings - -### 0.2 — C++ Wrapper Interface Design (BLOCKING for Phases 1-3) - -- [ ] **0.2.1** [PROG-B] Define `quickbms_wrapper.h` — clean C++ interface over QuickBMS C internals: - ```cpp - namespace quickbms { - struct file_entry { - std::string name; - int64_t offset; - int64_t size; // uncompressed - int64_t packed_size; // compressed - }; - - // Progress callback: receives context + bytes written. - // Return true to continue, false to abort. - using progress_callback = std::function; - - class engine { - public: - engine(); - ~engine(); - bool load_script(const std::filesystem::path &bms_path); - bool open_archive(const std::filesystem::path &archive_path); - std::vector list_files(); - bool extract_file(size_t index, const std::filesystem::path &dest_path, - progress_callback progress); - void reset(); - private: - struct impl; - std::unique_ptr impl_; - }; - } - ``` - - Pimpl hides all QuickBMS globals - - Each `engine` instance assumes exclusive access (QuickBMS global state) - - `reset()` calls `bms_init(0)` to clean up between uses - - `extract_file()` sets `g_observer_progress_callback` / `g_observer_progress_context` - before calling `start_bms()`, resets them after. The patched `dumpa()` calls back - per write chunk, giving real-time progress and user abort support. -- [ ] **0.2.2** [REVIEW-B] Review wrapper interface — check: no C types leak, lifecycle correct, thread-safety documented (single-threaded only) -- [ ] **0.2.3** [PROG-B] Fix review findings - -### 0.3 — scripts.ini Parser Design (parallel with 0.1, 0.2) - -- [ ] **0.3.1** [PROG-C] Define `scripts_config.h`: - ```cpp - namespace quickbms { - struct script_mapping { - std::string extension; // e.g. "xp3" - std::vector scripts; // e.g. ["kirikiri_xp3.bms"] - }; - - class scripts_config { - public: - explicit scripts_config(const std::filesystem::path &ini_path); - std::vector find_scripts(const std::string &extension) const; - std::vector all_extensions() const; - private: - std::vector mappings_; - }; - } - ``` -- [ ] **0.3.2** [REVIEW-C] Review config interface — check: case-insensitive extension matching, handles dots/wildcards correctly -- [ ] **0.3.3** [PROG-C] Fix review findings - ---- - -## Phase 1: scripts.ini Parser - -Can start immediately after 0.3 is finalized. - -### 1.1 — Implementation - -- [ ] **1.1.1** [PROG-C] Write tests for `scripts_config`: - - Parse valid INI → correct mappings - - Extension lookup case-insensitive ("XP3" == "xp3") - - Multiple scripts per extension → returned in order - - Unknown extension → empty vector - - `all_extensions()` returns sorted unique list - - Missing file → throws - - Empty file → no mappings - - Comments (# lines) ignored - - Malformed lines skipped gracefully -- [ ] **1.1.2** [PROG-C] Implement `scripts_config.cpp` -- [ ] **1.1.3** [PROG-C] Verify tests pass, 100% line+branch coverage -- [ ] **1.1.4** [REVIEW-C] Review — check: no buffer overflows on long lines, handles BOM, handles \r\n and \n -- [ ] **1.1.5** [PROG-C] Fix review findings - ---- - -## Phase 2: QuickBMS Wrapper - -Depends on Phase 0.1 (static lib) and 0.2 (wrapper interface). -Can run **in parallel** with Phase 1 and Phase 3. - -### 2.1 — Init / Reset / Cleanup - -- [ ] **2.1.1** [PROG-A] Write tests for `quickbms::engine` construction and destruction: - - Constructor initializes QuickBMS (`quickbms_dll_init`, `bms_init`) - - Destructor cleans up (`bms_finish`) - - `reset()` clears state for reuse - - Double reset is safe -- [ ] **2.1.2** [PROG-A] Implement constructor, destructor, `reset()` in `quickbms_wrapper.cpp` -- [ ] **2.1.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **2.1.4** [REVIEW-A] Review — check: all QuickBMS globals properly initialized, no memory leaks from QuickBMS internal allocations -- [ ] **2.1.5** [PROG-A] Fix review findings - -### 2.2 — Script Loading - -- [ ] **2.2.1** [PROG-A] Write tests for `load_script()`: - - Valid .bms file → returns true - - Non-existent file → returns false - - Invalid/empty script → returns false - - Load replaces previous script (after reset) -- [ ] **2.2.2** [PROG-A] Implement `load_script()`: - - Call `bms_init(0)` to reset state - - Open script file - - Call `parse_bms(fds, NULL, 0, 0)` - - Return success/failure -- [ ] **2.2.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **2.2.4** [REVIEW-A] Review — check: file handle closed on error, state consistent after failure -- [ ] **2.2.5** [PROG-A] Fix review findings - -### 2.3 — Archive Opening + File Listing - -- [ ] **2.3.1** [PROG-A] Write tests for `open_archive()` + `list_files()`: - - Valid archive + matching script → file list with correct names, sizes - - Wrong script for archive → returns false or empty list - - `list_files()` without `open_archive()` → empty list - - File names contain subdirectories → paths preserved - - Packed entries → packed_size != size -- [ ] **2.3.2** [PROG-A] Implement `open_archive()` and `list_files()`: - - `open_archive()`: call `fdnum_open(path, 0, 1)` - - `list_files()`: set `g_list_only = 1`, call `start_bms(...)`, iterate `g_extracted_file` linked list - - Convert `extracted_file_t` → `quickbms::file_entry` -- [ ] **2.3.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **2.3.4** [REVIEW-A] Review — check: g_list_only properly set/unset, extracted_file_t iteration safe, memory ownership -- [ ] **2.3.5** [PROG-A] Fix review findings - -### 2.4 — File Extraction with Progress - -- [ ] **2.4.1** [PROG-A] Write tests for `extract_file()`: - - Extract known file → output matches expected bytes (hash check) - - Extract compressed file → decompressed correctly - - Progress callback called with byte counts (per write chunk from `dumpa()`) - - Progress callback returning false → extraction aborts, returns false - - After abort, engine is in clean state (can extract another file) - - Index out of range → returns false - - Dest path with subdirectories → created automatically -- [ ] **2.4.2** [PROG-A] Implement `extract_file()`: - - Set `g_list_only = 0`, `g_void_dump = 0` - - Set `g_output_folder` to dest directory - - Install progress hook: set `g_observer_progress_callback` to a static trampoline - that calls the `progress_callback`, set `g_observer_progress_context` to `this` - - Re-run `start_bms(...)` — QuickBMS re-executes script and extracts - - Filter output to only the requested file (by index/name) - - `dumpa()` calls our hook per write chunk → real-time progress to Observer - - If hook returns false (user abort), `dumpa()` returns -1, script terminates - - Uninstall progress hook after extraction (set both globals to NULL) - - Note: QuickBMS re-executes entire script per extract; acceptable for typical use -- [ ] **2.4.3** [PROG-A] Verify tests pass, 100% coverage -- [ ] **2.4.4** [REVIEW-A] Review — check: hook installed/uninstalled correctly (RAII guard), output path injection (sanitize filenames), abort leaves no partial files, re-execution overhead acceptable -- [ ] **2.4.5** [PROG-A] Fix review findings - ---- - -## Phase 3: Archive Layer + DLL Entry Points - -Can run **in parallel** with Phase 2 using mock wrapper. - -### 3.1 — Mock Wrapper - -- [ ] **3.1.1** [PROG-B] Create `mock_quickbms_wrapper.h/cpp` — test double for `quickbms::engine`: - - Configurable: set file list, set extract behavior - - Tracks calls: script loaded, archive opened, files extracted - - Configurable error injection -- [ ] **3.1.2** [REVIEW-B] Review mock -- [ ] **3.1.3** [PROG-B] Fix review findings - -### 3.2 — quickbms_archive - -- [ ] **3.2.1** [PROG-B] Write tests for `quickbms_archive`: - - `open(path)` → reads scripts.ini, finds scripts for extension, tries each until one works - - `open(path)` with unmapped extension → throws (SOR_INVALID_FILE) - - `open(path)` with mapped extension but wrong format → tries all scripts, then throws - - `prepare_files()` → delegates to engine.list_files() - - `get_file(index)` → returns file info - - `get_file()` out of range → throws out_of_range - - `extract_file()` → delegates to engine.extract_file() with progress callback - - `extract_file()` user abort → throws user_interrupt - - Path separators normalized to backslash - - `archive_info` format field contains BMS script name or detected format -- [ ] **3.2.2** [PROG-B] Implement `quickbms_archive` in `src/modules/quickbms/quickbms_archive.h/cpp`: - - Owns `quickbms::engine` and `quickbms::scripts_config` - - On `open()`: get extension, look up scripts, try each with engine - - Stores successful script name and file list -- [ ] **3.2.3** [PROG-B] Verify tests pass, 100% coverage -- [ ] **3.2.4** [REVIEW-B] Review — check: exception translation, script path resolution, lifecycle -- [ ] **3.2.5** [PROG-B] Fix review findings - -### 3.3 — dll.cpp - -- [ ] **3.3.1** [PROG-C] Write tests for Observer API functions: - - `LoadSubModule` → fills ModuleId, ModuleVersion, ApiVersion, ApiFuncs - - `OpenStorage` → valid archive with mapped extension → SOR_SUCCESS - - `OpenStorage` → unmapped extension → SOR_INVALID_FILE - - `CloseStorage` → no crash (null and valid handle) - - `PrepareFiles` → TRUE after open - - `GetItem` → correct file info, GET_ITEM_NOMOREITEMS at end - - `ExtractItem` → SER_SUCCESS, file created, SER_USERABORT on cancel - - `UnloadSubModule` → clean shutdown -- [ ] **3.3.2** [PROG-C] Implement `src/modules/quickbms/dll.cpp`: - - `LoadSubModule` → resolve module dir via `GetModuleFileName`, load scripts.ini from `quickbms/` subfolder - - `OpenStorage` → create quickbms_archive, try open - - Other functions follow existing dll.cpp pattern -- [ ] **3.3.3** [PROG-C] Create `src/modules/quickbms/quickbms.def` -- [ ] **3.3.4** [PROG-C] Verify tests pass, 100% coverage -- [ ] **3.3.5** [REVIEW-C] Review dll.cpp — check: no exceptions escape extern "C", handle lifecycle, path resolution -- [ ] **3.3.6** [PROG-C] Fix review findings - ---- - -## Phase 4: Integration Testing - -Depends on Phases 1, 2, 3 all complete. - -### 4.1 — End-to-End Tests - -- [ ] **4.1.1** [PROG-D] Select 3-5 test archives with publicly available BMS scripts: - - At least one simple format (no compression) - - At least one with compression - - At least one with encryption - - Small archives (< 1MB each) -- [ ] **4.1.2** [PROG-D] Create scripts.ini with mappings for test archives -- [ ] **4.1.3** [PROG-D] Generate `.expected.json` baselines (hash + size) -- [ ] **4.1.4** [PROG-D] Write `src/tests/quickbms.cpp` — Catch2 tests using `test::observer` framework: - - Load quickbms.so module - - Open each test archive - - List files, verify count and names - - Extract all files, verify hashes -- [ ] **4.1.5** [PROG-D] Run integration tests, fix failures -- [ ] **4.1.6** [REVIEW-D] Review — check: deterministic, no hardcoded paths, temp cleanup -- [ ] **4.1.7** [PROG-D] Fix review findings - -### 4.2 — Coexistence Test - -- [ ] **4.2.1** [PROG-D] Test that quickbms.so, garbro.so, and existing modules load simultaneously -- [ ] **4.2.2** [PROG-D] Test module priority: native modules → garbro → quickbms (by extension ordering in observer_user.ini) - ---- - -## Phase 5: Documentation & Example Scripts - -Can run **in parallel** with Phase 4. - -### 5.1 — BMS Syntax Reference - -- [ ] **5.1.1** [PROG-D] Find or write `quickbms/docs/bms_syntax.md`: - - Core commands: Get, Set, Log, CLog, GoTo, For/Next, If/Else/EndIf - - Data types: Long, Short, Byte, String, ThreeByte, etc. - - Math operations - - Compression (ComType) and Encryption commands - - Variables: FILENAME, FILESIZE, etc. - - Example script walkthrough - - Link to full QuickBMS documentation (zenhax.com/quickbms) -- [ ] **5.1.2** [REVIEW-D] Review documentation — check: accurate, covers essentials for writing custom scripts -- [ ] **5.1.3** [PROG-D] Fix review findings - -### 5.2 — Example Scripts - -- [ ] **5.2.1** [PROG-D] Bundle 5-10 popular BMS scripts in `quickbms/scripts/`: - - Include license/attribution for each script - - Cover variety: simple raw, compressed, encrypted - - Include a `README.txt` in scripts/ explaining how to add more -- [ ] **5.2.2** [REVIEW-D] Review script selection — check: licenses allow redistribution, scripts tested -- [ ] **5.2.3** [PROG-D] Fix review findings - -### 5.3 — observer_user.ini Generator - -- [ ] **5.3.1** [PROG-C] Create script/tool that reads `scripts.ini` and generates `observer_user.ini`: - - Extracts all extensions from `[Scripts]` section - - Outputs `[Filters]` with comma-separated `*.ext` list - - Simple: Python script or shell script (no need for C#) -- [ ] **5.3.2** [REVIEW-C] Review generator -- [ ] **5.3.3** [PROG-C] Fix review findings - ---- - -## Phase 6: Packaging - -### 6.1 — CPack Integration - -- [ ] **6.1.1** [PROG-B] Add CPack rules for quickbms module: - - `quickbms-{DATE}-{ARCH}-dll.zip` contents: - - `quickbms.so` (platform-specific) - - `observer_user.ini` (generated) - - `quickbms/scripts.ini` - - `quickbms/scripts/*.bms` (example scripts) - - `quickbms/docs/bms_syntax.md` - - `licenses/` - - `quickbms-{DATE}-{ARCH}-pdb.zip` with debug symbols - - Both x86 and x64 packages -- [ ] **6.1.2** [REVIEW-B] Review packaging — check: all files included, scripts.ini editable post-install -- [ ] **6.1.3** [PROG-B] Fix review findings - ---- - -## Phase 7: Final Review - -- [ ] **7.1** [REVIEW-ALL] Full code review: - - Consistent style with existing ObserverModules code - - No memory leaks (QuickBMS global state properly managed) - - No exceptions escaping extern "C" functions - - All error paths tested - - 100% line + branch coverage confirmed - - QuickBMS global state properly reset between archives -- [ ] **7.2** [PROG-ALL] Fix all final review findings -- [ ] **7.3** [REVIEW-ALL] Confirm zero open findings -- [ ] **7.4** Run full test suite (all modules), both x86 and x64 -- [ ] **7.5** Build release packages for x86 and x64 - ---- - -## Parallelism Map - -``` -Phase 0.1 (quickbms.lib) Phase 0.2 (wrapper.h) Phase 0.3 (config.h) -[PROG-A] [PROG-B] [PROG-C] - │ │ │ - │ │ ▼ - │ │ Phase 1 - │ │ (config parser) - │ │ [PROG-C] - │ │ │ - ├─────────────────────────────┘ │ - ▼ │ -Phase 2 Phase 3.1 (mock) │ -(wrapper impl) [PROG-B] │ -[PROG-A] │ │ - │ ▼ │ - │ Phase 3.2-3.3 │ - │ (archive + dll.cpp) │ - │ [PROG-B, PROG-C] │ - │ │ │ - └─────────────────────────────┴─────────────────────────┘ - │ - ▼ - Phase 4 (integration) ←→ Phase 5 (docs + scripts) - [PROG-D] [PROG-D, PROG-C] - │ - ▼ - Phase 6 (packaging) - [PROG-B] - │ - ▼ - Phase 7 (final review) -``` - -## Agent Roles - -| Role | Responsibility | -|------|---------------| -| **PROG-A** | QuickBMS static lib build + C++ wrapper (`quickbms_wrapper.*`) | -| **PROG-B** | CMake integration + mock + archive layer + packaging | -| **PROG-C** | scripts.ini parser + dll.cpp + observer_user.ini generator | -| **PROG-D** | Integration tests + documentation + example scripts | -| **REVIEW-A** | Reviews PROG-A output | -| **REVIEW-B** | Reviews PROG-B output | -| **REVIEW-C** | Reviews PROG-C output | -| **REVIEW-D** | Reviews PROG-D output | -| **REVIEW-ALL** | Final cross-cutting review | - -## Technical Notes - -- **Symbol visibility**: `quickbms.so` must export ONLY `LoadSubModule` and `UnloadSubModule` (via `.def` file). All QuickBMS internals (700+ compression funcs, crypto, globals) must be hidden: - - `.def` file lists only the two exports — MSVC exports nothing else by default for DLLs with `.def` - - QuickBMS static lib (`quickbms_lib`) is linked with `/OPT:REF` to strip unused code - - All QuickBMS source compiled without `__declspec(dllexport)` — verify no accidental exports - - This also solves symbol conflicts: if `garbro.so` and `quickbms.so` both link zlib, their internal zlib symbols are hidden and don't clash -- **Progress callback**: Patched `dumpa()` in `file.c` calls `g_observer_progress_callback` after each write. This gives real-time progress bars in FAR Manager and user abort support (callback returns false → `dumpa()` returns -1 → script terminates). -- **Global state**: QuickBMS uses ~50 global variables. Only one archive can be open at a time. `bms_init(0)` resets everything between uses. -- **Build quirk**: QuickBMS `#include`s .c files from `quickbms.c`. To build as library, either: - - Compile `quickbms.c` with a macro that excludes `main()`, OR - - Extract the `#include` list and compile files separately -- **Re-execution on extract**: `start_bms()` must re-run the entire script to extract a file. For archives with many files, this means O(N) re-executions. Acceptable for typical use (user extracts one file at a time in FAR Manager). -- **Output redirection**: QuickBMS writes to `g_output_folder`. For `ExtractItem`, set this to the directory of `DestPath` and filter by filename match. -- **No thread safety**: QuickBMS is inherently single-threaded due to global state. Observer calls are sequential, so this is fine. -- **Compression deps**: All vendored in `extern/quickbms/compression/` and `libs/` — no external dependencies needed. diff --git a/docs/unity.md b/docs/unity.md deleted file mode 100644 index 3a0986e..0000000 --- a/docs/unity.md +++ /dev/null @@ -1,318 +0,0 @@ -# Unity Observer Module — План реализации - -## Архитектура - -``` -dll.cpp (C, экспорты Observer API) - → unity_archive.h/cpp (чистый C++, оркестрация) - → bridge.h/cpp (C++/CLI за pimpl, общение с AssetStudio) - → AssetStudio.dll (ILRepack-merged: AssetStudioUtility + все зависимости) - → Texture2DDecoderNative.dll (нативный C++ декодер текстур, загружается через P/Invoke) -``` - -Только `bridge.cpp` компилируется с `/clr`. Остальные файлы — чистый нативный C++. - -НЕ использует `extractor.h` — объекты Unity десериализуются AssetStudio и экспортируются -с конвертацией формата (Texture2D → PNG, AudioClip → WAV и т.д.), что несовместимо -с chunk-based `decrypt()` моделью. - -### Раскладка дистрибутива - -``` -modules/ -├── unity.so ← C++/CLI mixed-mode DLL (платформо-зависимый: x86 или x64) -├── observer_user.ini ← фильтр расширений -└── unity/ - ├── AssetStudio.dll ← ILRepack-merged сборка (Any CPU, ~10-15 MB) - └── Texture2DDecoderNative.dll ← нативный декодер текстур (платформо-зависимый, ~500 KB) -``` - -### Раскладка репозитория - -``` -ObserverModules/ -├── extern/ -│ └── AssetStudio/ ← git submodule (aelurum/AssetStudio) -├── src/ -│ ├── api.h -│ ├── modules/ -│ │ └── unity/ -│ │ ├── bridge.h ← чистый C++ интерфейс (pimpl) -│ │ ├── bridge.cpp ← C++/CLI реализация (/clr) -│ │ ├── unity_archive.h ← чистый C++ обёртка архива -│ │ ├── unity_archive.cpp -│ │ ├── dll.cpp ← экспорты Observer API (чистый C) -│ │ ├── unity.def ← экспорты DLL -│ │ └── observer_user.ini ← фильтр расширений -│ └── tests/ -│ ├── unity.cpp ← интеграционные тесты -│ └── framework/ -└── CMakeLists.txt -``` - -### CI Pipeline - -``` -Step 1: git submodule update --init (AssetStudio) -Step 2: dotnet restore extern/AssetStudio -Step 3: dotnet build extern/AssetStudio -c Release -f net472 -Step 4: ilrepack /out:AssetStudio.dll AssetStudioUtility.dll -Step 5: cmake --preset x64-release && cmake --build build/x64-release -Step 6: cmake --preset x86-release && cmake --build build/x86-release -Step 7: ctest (все тесты) -Step 8: cpack (пакеты x86 + x64 ZIP) -``` - -### Маппинг форматов экспорта - -При листинге файлов каждый Unity-объект показывается с соответствующим расширением. -При извлечении AssetStudio конвертирует в этот формат. - -| Тип Unity | Формат экспорта | Расширение | Примечания | -|-----------|----------------|------------|------------| -| Texture2D | PNG | `.png` | GPU-форматы декодируются через Texture2DDecoderNative | -| Sprite | PNG | `.png` | Обрезается по атласу | -| AudioClip | WAV | `.wav` | FSB/FMOD; может потребоваться нативная FMOD-библиотека | -| TextAsset | Сырые байты | `.txt` / `.bytes` | Как есть, расширение из оригинального имени | -| Font | TrueType | `.ttf` / `.otf` | Сырые данные шрифта | -| Shader | Текст | `.shader` | Исходник или дизассемблированный код | -| VideoClip | Ссылка | `.mp4` и т.д. | Обычно внешний .resource файл | -| Mesh | Сырые данные | `.mesh.bytes` | Нет стандартного просмотрщика | -| MonoBehaviour | JSON | `.json` | Сериализованные поля (если доступен type tree) | -| Прочее | Сырые байты | `.bytes` | Фоллбэк для неподдерживаемых типов | - ---- - -## Фаза 0: Подготовка - -### 0.1 — Проектирование интерфейса (БЛОКИРУЕТ остальные фазы) - -- [ ] **0.1.1** [PROG-A] Спроектировать `bridge.h` — чистый C++ интерфейс с pimpl: - - Namespace `unity`, класс `bundle` с методами: `try_open`, `format`, `asset_count`, `get_asset`, `extract` - - Структура `asset_info` (имя, тип, размер экспорта, оригинальный размер) - - Enum `asset_type` для маппинга типов Unity - - Свободные функции `init(module_dir_path)` / `shutdown()` — загрузка AssetStudio.dll из подпапки `unity/` - - Managed-типы не должны утекать наружу -- [ ] **0.1.2** [REVIEW-A] Ревью `bridge.h` -- [ ] **0.1.3** [PROG-A] Исправления по ревью - -### 0.2 — Скелет системы сборки (после 0.1.1) - -- [ ] **0.2.1** [PROG-B] Добавить aelurum/AssetStudio как git submodule в `extern/AssetStudio` -- [ ] **0.2.2** [PROG-B] Добавить таргет `unity` в `CMakeLists.txt`: - - Shared library → `unity.so` - - `bridge.cpp` с `/clr` + `/EHa` (per-file property) - - Весь модуль с `/MD` (динамический CRT, требование `/clr`) - - `unity.def` с экспортами `LoadSubModule` / `UnloadSubModule` -- [ ] **0.2.3** [PROG-B] Проверить что скелет компилируется (пустые заглушки) -- [ ] **0.2.4** [REVIEW-B] Ревью CMake -- [ ] **0.2.5** [PROG-B] Исправления по ревью - -### 0.3 — Сборка AssetStudio + ILRepack (параллельно с 0.2) - -- [ ] **0.3.1** [PROG-B] Создать скрипт `scripts/build_assetstudio.bat`: - - Сборка AssetStudioUtility под net472 - - ILRepack всех managed-зависимостей в одну `AssetStudio.dll` - - Сборка `Texture2DDecoderNative.dll` под x86 и x64 - - Копирование артефактов в build output -- [ ] **0.3.2** [PROG-B] Проверить что ILRepack-merged сборка работает: AssetsManager инициализируется, открывает тестовый .assets файл -- [ ] **0.3.3** [REVIEW-B] Ревью скрипта сборки -- [ ] **0.3.4** [PROG-B] Исправления по ревью - ---- - -## Фаза 1: Bridge Layer (C++/CLI ↔ AssetStudio) - -Может идти **параллельно** с фазой 2 после финализации `bridge.h`. - -### 1.1 — Init/Shutdown - -- [ ] **1.1.1** [PROG-A] Тесты + реализация `unity::init()` / `unity::shutdown()`: - - `GetModuleFileName()` → определение пути к своей DLL - - Загрузка AssetStudio.dll через `Assembly::LoadFrom()` - - `AddDllDirectory()` для подпапки `unity/` — чтобы P/Invoke нашёл Texture2DDecoderNative.dll - - Идемпотентность, безопасность повторных вызовов -- [ ] **1.1.2** [REVIEW-A] Ревью -- [ ] **1.1.3** [PROG-A] Исправления по ревью - -### 1.2 — Открытие ассетов (try_open) - -- [ ] **1.2.1** [PROG-A] Тесты + реализация `unity::bundle::try_open()`: - - `AssetsManager` в pimpl через `gcroot<>` - - `LoadFiles(path)` → перечисление всех `SerializedFile` и их объектов - - Построение списка `asset_info` из экспортируемых объектов - - Формирование строки формата ("Unity AssetBundle (LZ4)", "Unity Assets" и т.д.) - - Невалидный/несуществующий/пустой файл → false без исключений -- [ ] **1.2.2** [REVIEW-A] Ревью -- [ ] **1.2.3** [PROG-A] Исправления по ревью - -### 1.3 — Листинг ассетов (asset_count / get_asset) - -- [ ] **1.3.1** [PROG-A] Тесты + реализация: - - Итерация объектов, фильтрация экспортируемых типов - - Маппинг в `asset_info` с именем + расширение экспорта - - Дедупликация имён (суффикс `_N` при коллизиях) - - Вложенные .assets → плоский список -- [ ] **1.3.2** [REVIEW-A] Ревью -- [ ] **1.3.3** [PROG-A] Исправления по ревью - -### 1.4 — Извлечение с конвертацией (extract) - -- [ ] **1.4.1** [PROG-A] Тесты + реализация: - - По типу объекта: Texture2D → PNG, Sprite → PNG, AudioClip → WAV, TextAsset → as-is, Font → TTF, Shader → текст, MonoBehaviour → JSON (если есть type tree), прочее → сырые байты - - Запись чанками с вызовом progress callback - - Прерывание по callback returning false -- [ ] **1.4.2** [REVIEW-A] Ревью — особое внимание: memory pressure на больших текстурах, disposal потоков, exception safety -- [ ] **1.4.3** [PROG-A] Исправления по ревью - -### 1.5 — Деструктор / очистка ресурсов - -- [ ] **1.5.1** [PROG-A] Тесты + реализация: dispose gcroot, освобождение AssetsManager, move-семантика -- [ ] **1.5.2** [REVIEW-A] Ревью -- [ ] **1.5.3** [PROG-A] Исправления по ревью - ---- - -## Фаза 2: Слой архива (чистый C++) - -Может идти **параллельно** с фазой 1 после финализации `bridge.h`. -Юнит-тесты через мок bridge. - -### 2.1 — Мок + Archive Wrapper - -- [ ] **2.1.1** [PROG-B] Мок `unity::bundle` для юнит-тестов (настраиваемый список файлов, поведение extract, инъекция ошибок) -- [ ] **2.1.2** [PROG-B] Тесты + реализация `unity_archive` в `unity_archive.h/cpp`: - - Обёртка над `unity::bundle` - - Аналогичный паттерн `archive::archive`, но без зависимости от `extractor.h` - - Конвертация `unity::asset_info` → внутренняя структура файла - - Трансляция исключений, нормализация путей -- [ ] **2.1.3** [REVIEW-B] Ревью -- [ ] **2.1.4** [PROG-B] Исправления по ревью - ---- - -## Фаза 3: Точки входа DLL (Observer API) - -Зависит от стабильного интерфейса фазы 2. Тесты можно писать параллельно с фазами 1+2. - -### 3.1 — dll.cpp для Unity-модуля - -- [ ] **3.1.1** [PROG-C] Тесты для всех функций Observer API (`OpenStorage`, `CloseStorage`, `PrepareFiles`, `GetItem`, `ExtractItem`, `LoadSubModule`/`UnloadSubModule`) -- [ ] **3.1.2** [PROG-C] Реализация `dll.cpp` по паттерну существующих модулей -- [ ] **3.1.3** [PROG-C] Создать `unity.def` и `observer_user.ini` (расширения: `*.assets`, `*.unity3d`, `*.bundle`, `*.ab`) -- [ ] **3.1.4** [REVIEW-C] Ревью — особое внимание: исключения не должны вылетать из extern "C" -- [ ] **3.1.5** [PROG-C] Исправления по ревью - ---- - -## Фаза 4: Интеграционное тестирование - -Зависит от фаз 1, 2, 3. - -### 4.1 — End-to-End тесты - -- [ ] **4.1.1** [PROG-D] Подготовить тестовые Unity-файлы (< 1 MB): - - AssetBundle с Texture2D (LZ4) - - AssetBundle с AudioClip - - Raw .assets с TextAsset - - AssetBundle со смешанными типами - - Битый/пустой файл для проверки ошибок -- [ ] **4.1.2** [PROG-D] Написать `src/tests/unity.cpp` — Catch2 тесты через `test::observer`: - - Загрузка unity.so, открытие файлов, листинг, извлечение, проверка хешей -- [ ] **4.1.3** [REVIEW-D] Ревью тестов -- [ ] **4.1.4** [PROG-D] Исправления по ревью - -### 4.2 — Сосуществование и смоук-тесты - -- [ ] **4.2.1** [PROG-D] Проверить одновременную загрузку unity.so с другими модулями (renpy, rpgmaker, zanzarah, garbro) — отсутствие конфликтов CLR -- [ ] **4.2.2** [PROG-D] Смоук-тесты на реальных играх: Unity 5.x, Unity 2019–2021, Unity 2022+ — проверить что текстуры рендерятся, аудио воспроизводится - ---- - -## Фаза 5: Пакетирование - -Может начинаться параллельно с фазой 4. - -- [ ] **5.1** [PROG-B] CPack-правила для модуля unity: - - `unity-{DATE}-{ARCH}-dll.zip`: `unity.so`, `observer_user.ini`, `unity/AssetStudio.dll`, `unity/Texture2DDecoderNative.dll`, `licenses/` - - PDB-пакет отдельно - - x86 и x64 (AssetStudio.dll общая, нативные DLL платформо-зависимые) -- [ ] **5.2** [REVIEW-B] Ревью пакетирования -- [ ] **5.3** [PROG-B] Исправления по ревью - ---- - -## Фаза 6: Финальное ревью - -- [ ] **6.1** [REVIEW-ALL] Полное ревью всего кода: стиль, утечки памяти на границе managed/native, исключения, покрытие тестами -- [ ] **6.2** [PROG-ALL] Исправления -- [ ] **6.3** Полный прогон тестов (все модули включая unity), x86 и x64 -- [ ] **6.4** Сборка релизных пакетов - ---- - -## Карта параллелизма - -``` -Фаза 0.1 (интерфейс bridge.h) - │ - ├───────────────────────┐ - ▼ ▼ -Фаза 0.2 Фаза 0.3 -(скелет CMake) (сборка AssetStudio -[PROG-B] + ILRepack) - [PROG-B] - │ │ - ├───────────────────────┘ - │ - ├──────────────────┬──────────────────┐ - ▼ ▼ ▼ -Фаза 1 Фаза 2 Фаза 3 (тесты) -(bridge impl) (слой архива) (dll.cpp тесты) -[PROG-A] [PROG-B] [PROG-C] - │ │ │ - └──────────────────┴──────────────────┘ - │ - ▼ - Фаза 3 (реализация) - │ - ▼ - Фаза 4 - (интеграция) - [PROG-D] - │ - ▼ - Фаза 5 - (пакетирование) - │ - ▼ - Фаза 6 - (финальное ревью) -``` - -## Роли агентов - -| Роль | Зона ответственности | -|------|---------------------| -| **PROG-A** | Bridge layer (C++/CLI) — `bridge.h`, `bridge.cpp`, тесты bridge | -| **PROG-B** | Система сборки + AssetStudio build + ILRepack + слой архива + мок + пакетирование | -| **PROG-C** | Точки входа DLL — `dll.cpp`, `unity.def`, `observer_user.ini`, тесты Observer API | -| **PROG-D** | Интеграционные тесты, смоук-тесты, тестовые данные | -| **REVIEW-*** | Ревью соответствующих PROG-агентов | -| **REVIEW-ALL** | Финальное сквозное ревью | - -## Технические заметки - -- `/clr` несовместим с `/EHsc` — для `bridge.cpp` использовать `/EHa` -- `/clr` несовместим со статическим CRT (`/MT`) — модуль unity целиком на `/MD`; остальные модули (renpy, rpgmaker, zanzarah) остаются на `/MT` -- `gcroot` — способ хранения managed-ссылок в нативных классах (внутри pimpl) -- Если модуль garbro тоже загружен — оба делят один CLR (оба под .NET Framework 4.7.2), конфликтов нет -- `AssetStudio.dll` — Any CPU, работает и в x86 и в x64 CLR -- `Texture2DDecoderNative.dll` — платформо-зависимая, должна соответствовать архитектуре unity.so -- **Резолв P/Invoke**: `unity::init()` должен вызвать `AddDllDirectory()` для подпапки `unity/`, чтобы P/Invoke из AssetStudio нашёл Texture2DDecoderNative.dll -- **Давление на память**: декодирование Texture2D может выделять большие RGBA-буферы (4096×4096 = 64 MB). Observer вызывает ExtractItem последовательно, так что это нормально. -- **AudioClip / FMOD**: часть аудио в Unity хранится в FSB5. AssetStudio (aelurum) включает поддержку FMOD — проверить что работает в net472 сборке. Если нужна нативная FMOD-библиотека, добавить в подпапку `unity/`. -- **Сжатие AssetBundle**: Unity использует LZMA (старые) и LZ4/LZ4HC (новые). AssetStudio обрабатывает оба прозрачно. -- **Type trees**: некоторые .assets файлы не содержат встроенных type trees. AssetStudio включает фоллбэк type trees для популярных версий Unity — убедиться что они попадают в ILRepack-merged сборку. -- Тестовые Unity-файлы < 1 MB, коммитятся в директорию тестовых данных. -- Обновление AssetStudio: `cd extern/AssetStudio && git pull && cd ../.. && git add extern/AssetStudio && git commit` diff --git a/src/tests/unit/bounded_stream.cpp b/src/tests/unit/bounded_stream.cpp index 1f29e19..7dcee06 100644 --- a/src/tests/unit/bounded_stream.cpp +++ b/src/tests/unit/bounded_stream.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -214,7 +215,12 @@ TEST_CASE("bounded stream: normalizes seek and read failures") { std::istringstream source("abc"); observer::io::bounded_stream input(source); - constexpr auto impossible_size = static_cast(std::numeric_limits::max()) + 1; - REQUIRE_THROWS_AS(input.read_exact(nullptr, impossible_size), observer::io::read_error); + if constexpr (std::numeric_limits::max() > + static_cast(std::numeric_limits::max())) { + constexpr auto impossible_size = static_cast(std::numeric_limits::max()) + 1; + REQUIRE_THROWS_AS(input.read_exact(nullptr, impossible_size), observer::io::read_error); + } else { + SUCCEED("size_t cannot represent a request larger than streamsize on this ABI"); + } } } diff --git a/tools/build/core/runtime.py b/tools/build/core/runtime.py index 7c2a3dd..ebd15f1 100644 --- a/tools/build/core/runtime.py +++ b/tools/build/core/runtime.py @@ -68,7 +68,7 @@ def lock(self, current: Node) -> AsyncFileLock: return self._lock(self.paths.lock(current.uid)) async def run(self, current: Node) -> None: - reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR") + reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR", "_MSPDBSRV_ENDPOINT_") existing = {key.casefold() for key, _value in current.command.env} for key in reserved: if key.casefold() in existing: @@ -76,9 +76,7 @@ async def run(self, current: Node) -> None: cas = self.store.prepare_entry(current) run_root = self.paths.run_work(self._run_id) - work = self.paths.require_confined( - run_root / f"{current.uid}-{current.name}", self.paths.work_root - ) + work = self.paths.require_confined(run_root / current.uid, self.paths.work_root) work.mkdir(exist_ok=False) command = Command( current.command.argv, @@ -86,6 +84,7 @@ async def run(self, current: Node) -> None: + ( ("OBSERVER_OUT_DIR", str(cas.output)), ("OBSERVER_BUILD_DIR", str(work)), + ("_MSPDBSRV_ENDPOINT_", f"observer_{current.uid}"), ), cwd=current.command.cwd, stdin=current.command.stdin, diff --git a/tools/build/templates/vcpkg.ps1 b/tools/build/templates/vcpkg.ps1 index 695888e..c449f69 100644 --- a/tools/build/templates/vcpkg.ps1 +++ b/tools/build/templates/vcpkg.ps1 @@ -3,6 +3,9 @@ Invoke-Checked {{ vcpkg | ps_quote }} @( 'install' "--x-install-root=$outDir" + "--x-buildtrees-root=$buildDir\b" + "--x-packages-root=$buildDir\p" + "--downloads-root=$buildDir\d" '--triplet' {{ triplet | ps_quote }} {{ ('--x-manifest-root=' ~ repository) | ps_quote }} diff --git a/tools/build/tests/test_msbuild_contracts.py b/tools/build/tests/test_msbuild_contracts.py new file mode 100644 index 0000000..00327ef --- /dev/null +++ b/tools/build/tests/test_msbuild_contracts.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}" + + +def _project(relative_path: str) -> ET.Element: + return ET.parse(REPOSITORY_ROOT / relative_path).getroot() + + +class MSBuildContractsTests(unittest.TestCase): + def test_analysis_reports_have_stable_unique_project_paths(self) -> None: + project = _project("build/ObserverProject.props") + + report_name = project.find(f".//{MSBUILD}ObserverAnalysisReportName") + self.assertIsNotNone(report_name) + self.assertEqual(report_name.text, "$(ProjectName)") + self.assertEqual( + report_name.get("Condition"), "'$(ObserverAnalysisReportName)' == ''" + ) + + report_logs = project.findall(f".//{MSBUILD}PREfastLog") + self.assertIn( + "$(ObserverAnalysisReportDirectory)\\$(PlatformMoniker)\\" + "$(ObserverAnalysisReportName).sarif", + (log.text for log in report_logs), + ) + + def test_fuzz_validation_allows_only_x64_fuzz_or_compile_analysis(self) -> None: + project = _project("build/ObserverFuzz.props") + validation = project.find( + f".//{MSBUILD}Target[@Name='ValidateFuzzConfiguration']/{MSBUILD}Error" + ) + + self.assertIsNotNone(validation) + self.assertEqual( + validation.get("Condition"), + "'$(ObserverCompileAnalysis)' != 'true' And " + "'$(Configuration)|$(Platform)' != 'Fuzz|x64'", + ) + + def test_leak_probe_is_shipping_x64_release_with_release_zlib(self) -> None: + project = _project("build/projects/leak-probe.vcxproj") + + runtimes = [ + node.text for node in project.findall(f".//{MSBUILD}RuntimeLibrary") + ] + dependencies = [ + node.text for node in project.findall(f".//{MSBUILD}AdditionalDependencies") + ] + validation = project.find( + f".//{MSBUILD}Target[@Name='ValidateLeakProbeConfiguration']/{MSBUILD}Error" + ) + + self.assertEqual(runtimes, ["MultiThreaded"]) + self.assertEqual(dependencies, ["zs.lib;%(AdditionalDependencies)"]) + self.assertIsNotNone(validation) + self.assertEqual( + validation.get("Condition"), + "'$(Configuration)|$(Platform)' != 'Release|x64'", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build/tests/test_runtime.py b/tools/build/tests/test_runtime.py index f29448c..c023e6b 100644 --- a/tools/build/tests/test_runtime.py +++ b/tools/build/tests/test_runtime.py @@ -77,7 +77,7 @@ async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) runtime.paths.prepare() current = node(env=(("Alpha", "one"),)) - work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + work = runtime.paths.run_work(RUN_ID) / current.uid with ( mock.patch("core.runtime.shutil.rmtree", wraps=shutil.rmtree) as remove, mock.patch("asyncio.to_thread", wraps=asyncio.to_thread) as offload, @@ -95,6 +95,7 @@ async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) "Alpha": "one", "OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(cas.output), + "_MSPDBSRV_ENDPOINT_": f"observer_{current.uid}", }, ) remove.assert_called_once_with(work) @@ -105,18 +106,58 @@ async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) self.assertEqual(cas.log.read_bytes(), b"runner log") self.assertFalse(cas.touch.exists()) + async def test_long_node_name_does_not_enter_mutable_scratch_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + current = node("restore-vcpkg-asan-x86-" + "dependency" * 10) + + await runtime.run(current) + + command, _log_path = runner.calls[0] + work = Path(dict(command.env)["OBSERVER_BUILD_DIR"]) + self.assertEqual(work, runtime.paths.run_work(RUN_ID) / current.uid) + self.assertNotIn(current.name, str(work)) + + async def test_runtime_injects_unique_mspdbsrv_endpoint_per_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + nodes = (node("build-renpy-x86-debug"), node("build-renpy-x64-debug")) + + for current in nodes: + await runtime.run(current) + + endpoints = tuple( + dict(command.env)["_MSPDBSRV_ENDPOINT_"] for command, _log in runner.calls + ) + self.assertEqual(endpoints, tuple(f"observer_{current.uid}" for current in nodes)) + self.assertEqual(len(set(endpoints)), len(nodes)) + async def test_run_rejects_case_insensitive_runtime_environment_conflicts(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" repository.mkdir() runner = FakeRunner() runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() with self.assertRaisesRegex(ValueError, "OBSERVER_OUT_DIR"): await runtime.run(node(env=(("observer_out_dir", "hostile"),))) self.assertEqual(runner.calls, []) + with self.assertRaisesRegex(ValueError, "_MSPDBSRV_ENDPOINT_"): + await runtime.run(node(env=(("_mspdbsrv_endpoint_", "shared"),))) + + self.assertEqual(runner.calls, []) + async def test_executor_runs_and_publishes_success(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" @@ -190,7 +231,7 @@ async def test_nonzero_exit_leaves_entry_incomplete_without_marker(self) -> None await runtime.run(current) cas = runtime.store.paths_for(current) - work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + work = runtime.paths.run_work(RUN_ID) / current.uid self.assertTrue(cas.entry.is_dir()) self.assertTrue(work.is_dir()) self.assertFalse(cas.touch.exists()) @@ -207,7 +248,7 @@ async def run(self, _command: Command, *, log) -> int: runtime = BuildRuntime(repository, RUN_ID, process_runner=CancelledRunner()) runtime.paths.prepare() current = node() - work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + work = runtime.paths.run_work(RUN_ID) / current.uid with self.assertRaises(asyncio.CancelledError): await runtime.run(current) @@ -229,7 +270,7 @@ async def run(self, _command: Command, *, log) -> int: runtime = BuildRuntime(repository, RUN_ID, process_runner=ReplacingRunner()) runtime.paths.prepare() current = node() - work = runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}" + work = runtime.paths.run_work(RUN_ID) / current.uid with ( mock.patch("core.paths._is_reparse", side_effect=lambda path: reparse and path == work), @@ -256,7 +297,7 @@ async def test_quarantine_survives_successful_scratch_cleanup(self) -> None: quarantine = runtime.paths.run_work(RUN_ID) / "quarantine" / old.entry.name self.assertEqual((quarantine / "out/partial.obj").read_bytes(), b"partial") - self.assertFalse((runtime.paths.run_work(RUN_ID) / f"{current.uid}-{current.name}").exists()) + self.assertFalse((runtime.paths.run_work(RUN_ID) / current.uid).exists()) if __name__ == "__main__": diff --git a/tools/build/tests/test_source_graph.py b/tools/build/tests/test_source_graph.py index ed7bcd3..b5ca391 100644 --- a/tools/build/tests/test_source_graph.py +++ b/tools/build/tests/test_source_graph.py @@ -72,7 +72,10 @@ def test_every_independent_source_check_is_a_demand_node(self) -> None: ) contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) - self.assertEqual(len(graph.nodes), 80) + self.assertEqual( + len(graph.nodes), + len(cpp_sources) + len(powershell_sources) + len(contracts) + 8, + ) self.assertEqual(graph.targets, ("source-checks",)) self.assertEqual(dict(graph.pools), {"restore": 1, "slot": 7}) self.assertEqual( @@ -178,11 +181,13 @@ def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(sel self.assertNotIn("PSScriptAnalyzer reported", pssa_script) self.assertIn("'psscriptanalyzer/build.ps1/'", pssa_script) - contract = graph.node("contract-build.tests.analysis-reporting.tests.ps1") - self.assertIn( - str(REPOSITORY / "build/tests/analysis-reporting.Tests.ps1"), - contract.command.stdin.decode(), - ) + contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) + self.assertTrue(contracts) + for test in contracts: + relative = test.relative_to(REPOSITORY).as_posix() + contract = graph.node(f"contract-{relative.lower().replace('/', '.')}") + self.assertEqual(contract.command.argv[0], r"C:\tools\pwsh.exe") + self.assertIn(str(test), contract.command.stdin.decode()) merge = graph.node("merge-source-findings") self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge")) @@ -238,13 +243,9 @@ def test_tool_identity_changes_only_affected_source_check_branch(self) -> None: def test_rendered_powershell_is_parseable(self) -> None: graph = self.graph() scripts = "\n".join( - graph.node(name).command.stdin.decode() - for name in ( - "format-src.modules.renpy.pickle.cpp", - "cppcheck-x64", - "pssa-build.ps1", - "contract-build.tests.analysis-reporting.tests.ps1", - ) + node.command.stdin.decode() + for node in graph.nodes + if node.command.argv[0] == r"C:\tools\pwsh.exe" ) parser = """ $tokens = $null diff --git a/tools/build/tests/test_vcpkg_template.py b/tools/build/tests/test_vcpkg_template.py index d652e76..850f1be 100644 --- a/tools/build/tests/test_vcpkg_template.py +++ b/tools/build/tests/test_vcpkg_template.py @@ -44,6 +44,15 @@ def test_manifest_restore_targets_node_output_and_requires_include_directory(sel ) self.assertIn("throw 'vcpkg restore did not produce the include directory'", script) + def test_mutable_vcpkg_scratch_is_confined_to_the_node_build_directory(self) -> None: + recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables)) + script = recipe["script"]["data"] + + self.assertIn('\n "--x-buildtrees-root=$buildDir\\b"\n', script) + self.assertIn('\n "--x-packages-root=$buildDir\\p"\n', script) + self.assertIn('\n "--downloads-root=$buildDir\\d"\n', script) + self.assertIn('\n "--x-install-root=$outDir"\n', script) + def test_triplet_is_required(self) -> None: del self.variables["triplet"] diff --git a/tools/build/tests/test_workflow_contract.py b/tools/build/tests/test_workflow_contract.py new file mode 100644 index 0000000..57238a1 --- /dev/null +++ b/tools/build/tests/test_workflow_contract.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +REPOSITORY = Path(__file__).parents[3] + + +class WorkflowContractTests(unittest.TestCase): + def test_main_ci_is_only_a_thin_public_verify_client(self) -> None: + workflow = (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8") + + self.assertEqual(workflow.count("runs-on:"), 1) + self.assertEqual(workflow.count("./build.ps1 doctor"), 1) + self.assertEqual(workflow.count("./build.ps1 verify"), 1) + self.assertIn("./build.ps1 verify -Arch x64", workflow) + self.assertNotIn("continue-on-error", workflow) + for private_protocol in ( + ".artifacts", + "upload-artifact", + "download-artifact", + "upload-sarif", + "test-reporter", + "codeql-action", + "action-gh-release", + "contents: write", + ): + self.assertNotIn(private_protocol, workflow) + for duplicated_gate in ( + "./build.ps1 source-checks", + "./build.ps1 compiler-analysis", + "./build.ps1 test-coverage", + "./build.ps1 test-asan", + "./build.ps1 test-ubsan", + "./build.ps1 test-leaks", + "./build.ps1 fuzz", + "./build.ps1 audit-binaries", + "./build.ps1 package", + ): + self.assertNotIn(duplicated_gate, workflow) + + +if __name__ == "__main__": + unittest.main() From fd85680149e83692f430f7829669ccb2855edb3f Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 12:11:11 +1000 Subject: [PATCH 05/17] fix: harden managed build fallbacks --- build/ObserverProject.props | 2 +- tools/build/tests/test_analysis_graph.py | 26 +++++++++++++++++++++ tools/build/tests/test_msbuild_contracts.py | 10 ++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/build/ObserverProject.props b/build/ObserverProject.props index 7b63c68..2625e00 100644 --- a/build/ObserverProject.props +++ b/build/ObserverProject.props @@ -2,7 +2,7 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\')) - $(RepositoryRoot).artifacts\ + $(RepositoryRoot)out\work\manual-msbuild\ x86 x64 arm64 diff --git a/tools/build/tests/test_analysis_graph.py b/tools/build/tests/test_analysis_graph.py index 77b8ab8..afcdf2d 100644 --- a/tools/build/tests/test_analysis_graph.py +++ b/tools/build/tests/test_analysis_graph.py @@ -2,10 +2,12 @@ from dataclasses import dataclass import json +import os from pathlib import Path import sys import tempfile import unittest +from unittest import mock BUILD_ROOT = Path(__file__).resolve().parents[1] @@ -13,6 +15,7 @@ from core.paths import BuildPaths # noqa: E402 from graphs.analysis import ( # noqa: E402 + _manifest_index, analysis_discovery_slice, analysis_slice, load_dependency_manifests, @@ -355,6 +358,29 @@ def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: self.assertEqual(loaded, expected) + def test_manifest_index_keeps_newest_candidate_regardless_of_enumeration_order(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + paths.prepare() + name = "discover-dependencies-x64-renpy-modules.renpy.pickle" + older = paths.cas("1" * 32, name) + newer = paths.cas("2" * 32, name) + for cas, content, timestamp in ( + (older, b"older", 100), + (newer, b"newer", 200), + ): + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(content) + cas.touch.touch() + os.utime(cas.touch, ns=(timestamp, timestamp)) + + with mock.patch.object(Path, "glob", return_value=(newer.entry, older.entry)): + loaded = _manifest_index(repository, {name}) + + self.assertEqual(loaded, {name: b"newer"}) + def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tools/build/tests/test_msbuild_contracts.py b/tools/build/tests/test_msbuild_contracts.py index 00327ef..4d34274 100644 --- a/tools/build/tests/test_msbuild_contracts.py +++ b/tools/build/tests/test_msbuild_contracts.py @@ -14,6 +14,16 @@ def _project(relative_path: str) -> ET.Element: class MSBuildContractsTests(unittest.TestCase): + def test_direct_msbuild_fallback_stays_below_managed_work_root(self) -> None: + project = _project("build/ObserverProject.props") + + artifacts_root = project.find(f".//{MSBUILD}ArtifactsRoot") + self.assertIsNotNone(artifacts_root) + self.assertEqual( + artifacts_root.text, + "$(RepositoryRoot)out\\work\\manual-msbuild\\", + ) + def test_analysis_reports_have_stable_unique_project_paths(self) -> None: project = _project("build/ObserverProject.props") From 43627badfb3b20f97d0a3da4a80abab401b0bc62 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 14:05:32 +1000 Subject: [PATCH 06/17] refactor: consolidate build system --- .github/workflows/main.yml | 4 +- .github/workflows/mutation.yml | 120 ---------------- .gitignore | 7 +- AGENTS.md | 4 +- README.md | 2 +- build.ps1 | 2 +- build/ObserverProject.props | 2 +- {tools/build => build}/core/__init__.py | 0 {tools/build => build}/core/binary_audit.py | 0 .../core/clang_dependencies.py | 0 {tools/build => build}/core/clean.py | 0 {tools/build => build}/core/cpp_coverage.py | 0 {tools/build => build}/core/doctor.py | 0 {tools/build => build}/core/execute.py | 0 {tools/build => build}/core/graph.py | 0 {tools/build => build}/core/host.py | 0 {tools/build => build}/core/leak.py | 0 {tools/build => build}/core/node.py | 0 {tools/build => build}/core/package.py | 0 {tools/build => build}/core/paths.py | 0 .../build => build}/core/python_coverage.py | 0 {tools/build => build}/core/quality_tools.py | 0 {tools/build => build}/core/recipe.py | 0 {tools/build => build}/core/render.py | 0 {tools/build => build}/core/runtime.py | 0 {tools/build => build}/core/sanitizer.py | 0 {tools/build => build}/core/sarif.py | 0 {tools/build => build}/core/sign.py | 0 {tools/build => build}/core/source_tools.py | 0 {tools/build => build}/core/store.py | 0 {tools/build => build}/core/toolchain.py | 0 {tools/build => build}/core/windows_job.py | 0 .../build => build}/core/windows_process.py | 0 {tools/build => build}/driver.py | 0 {tools/build => build}/graphs/__init__.py | 0 {tools/build => build}/graphs/analysis.py | 0 {tools/build => build}/graphs/audit.py | 0 {tools/build => build}/graphs/common.py | 2 +- {tools/build => build}/graphs/coverage.py | 0 {tools/build => build}/graphs/fuzz.py | 0 {tools/build => build}/graphs/instrumented.py | 0 {tools/build => build}/graphs/leak.py | 0 {tools/build => build}/graphs/native.py | 0 {tools/build => build}/graphs/package.py | 0 .../build => build}/graphs/python_coverage.py | 2 +- {tools/build => build}/graphs/sanitizer.py | 0 {tools/build => build}/graphs/source.py | 2 +- {tools/build => build}/main.py | 4 +- build/mutation/README.md | 36 ----- build/mutation/mull.yml | 17 --- build/mutation/run.sh | 79 ----------- build/mutation/validate_report.py | 86 ------------ {tools/build => build}/pyproject.toml | 0 {tools/build => build}/templates/analysis.ps1 | 0 {tools/build => build}/templates/argv.json | 0 {tools/build => build}/templates/base.json | 0 .../build => build}/templates/catch2-test.ps1 | 0 .../templates/clang-command.ps1 | 0 .../templates/clang-format.ps1 | 0 .../build => build}/templates/clang-tidy.ps1 | 0 {tools/build => build}/templates/contract.ps1 | 0 .../templates/coverage-corpus-test.ps1 | 0 .../templates/coverage-merge.ps1 | 0 .../templates/coverage-report.ps1 | 0 .../templates/coverage-test.ps1 | 0 {tools/build => build}/templates/cppcheck.ps1 | 0 .../build => build}/templates/fuzz-build.ps1 | 0 .../build => build}/templates/fuzz-common.ps1 | 0 .../build => build}/templates/fuzz-gate.ps1 | 0 .../build => build}/templates/fuzz-replay.ps1 | 0 {tools/build => build}/templates/fuzz-run.ps1 | 0 .../templates/instrumented-build.ps1 | 0 {tools/build => build}/templates/msbuild.ps1 | 0 .../templates/msvc-analyze.ps1 | 0 .../templates/native-build.ps1 | 0 .../templates/native-corpus-test.ps1 | 0 .../build => build}/templates/native-test.ps1 | 0 .../templates/psscriptanalyzer.ps1 | 0 {tools/build => build}/templates/pwsh.ps1 | 0 .../templates/sanitizer-test.ps1 | 0 {tools/build => build}/templates/script.json | 0 .../templates/selected-compile.ps1 | 0 .../templates/source-dependencies.ps1 | 0 {tools/build => build}/templates/vcpkg.ps1 | 0 build/tests/mutation-ci-contract.Tests.ps1 | 128 ------------------ .../tests/test_analysis_graph.py | 0 .../build => build}/tests/test_audit_graph.py | 2 +- .../tests/test_binary_audit.py | 0 .../tests/test_clang_dependencies.py | 0 {tools/build => build}/tests/test_clean.py | 0 {tools/build => build}/tests/test_common.py | 0 .../tests/test_core_coverage.py | 0 .../tests/test_coverage_config.py | 0 .../tests/test_cpp_coverage_graph.py | 2 +- {tools/build => build}/tests/test_doctor.py | 0 {tools/build => build}/tests/test_driver.py | 0 {tools/build => build}/tests/test_execute.py | 0 .../build => build}/tests/test_fuzz_graph.py | 0 {tools/build => build}/tests/test_graph.py | 0 .../tests/test_graph_main_coverage.py | 0 {tools/build => build}/tests/test_host.py | 0 .../tests/test_instrumented_graph.py | 0 .../build => build}/tests/test_leak_graph.py | 0 {tools/build => build}/tests/test_main.py | 5 +- .../tests/test_msbuild_contracts.py | 2 +- build/tests/test_mutation_report.py | 92 ------------- .../tests/test_native_graph.py | 2 +- {tools/build => build}/tests/test_node.py | 0 .../tests/test_package_graph.py | 0 {tools/build => build}/tests/test_paths.py | 0 .../tests/test_python_coverage_graph.py | 24 ++-- .../tests/test_quality_tools.py | 0 {tools/build => build}/tests/test_recipe.py | 0 {tools/build => build}/tests/test_render.py | 0 {tools/build => build}/tests/test_runtime.py | 0 .../tests/test_sanitizer_graph.py | 4 +- {tools/build => build}/tests/test_sarif.py | 0 {tools/build => build}/tests/test_sign.py | 0 .../tests/test_source_graph.py | 5 +- .../tests/test_source_tools.py | 0 {tools/build => build}/tests/test_store.py | 0 .../build => build}/tests/test_toolchain.py | 0 .../tests/test_vcpkg_template.py | 0 .../build => build}/tests/test_windows_job.py | 0 .../tests/test_windows_process.py | 0 .../tests/test_workflow_contract.py | 3 +- {tools/build => build}/uv.lock | 0 docs/build-system.md | 21 ++- docs/critical-software-methodology.md | 21 +-- src/tests/mutation/main.cpp | 6 - 130 files changed, 52 insertions(+), 634 deletions(-) delete mode 100644 .github/workflows/mutation.yml rename {tools/build => build}/core/__init__.py (100%) rename {tools/build => build}/core/binary_audit.py (100%) rename {tools/build => build}/core/clang_dependencies.py (100%) rename {tools/build => build}/core/clean.py (100%) rename {tools/build => build}/core/cpp_coverage.py (100%) rename {tools/build => build}/core/doctor.py (100%) rename {tools/build => build}/core/execute.py (100%) rename {tools/build => build}/core/graph.py (100%) rename {tools/build => build}/core/host.py (100%) rename {tools/build => build}/core/leak.py (100%) rename {tools/build => build}/core/node.py (100%) rename {tools/build => build}/core/package.py (100%) rename {tools/build => build}/core/paths.py (100%) rename {tools/build => build}/core/python_coverage.py (100%) rename {tools/build => build}/core/quality_tools.py (100%) rename {tools/build => build}/core/recipe.py (100%) rename {tools/build => build}/core/render.py (100%) rename {tools/build => build}/core/runtime.py (100%) rename {tools/build => build}/core/sanitizer.py (100%) rename {tools/build => build}/core/sarif.py (100%) rename {tools/build => build}/core/sign.py (100%) rename {tools/build => build}/core/source_tools.py (100%) rename {tools/build => build}/core/store.py (100%) rename {tools/build => build}/core/toolchain.py (100%) rename {tools/build => build}/core/windows_job.py (100%) rename {tools/build => build}/core/windows_process.py (100%) rename {tools/build => build}/driver.py (100%) rename {tools/build => build}/graphs/__init__.py (100%) rename {tools/build => build}/graphs/analysis.py (100%) rename {tools/build => build}/graphs/audit.py (100%) rename {tools/build => build}/graphs/common.py (98%) rename {tools/build => build}/graphs/coverage.py (100%) rename {tools/build => build}/graphs/fuzz.py (100%) rename {tools/build => build}/graphs/instrumented.py (100%) rename {tools/build => build}/graphs/leak.py (100%) rename {tools/build => build}/graphs/native.py (100%) rename {tools/build => build}/graphs/package.py (100%) rename {tools/build => build}/graphs/python_coverage.py (98%) rename {tools/build => build}/graphs/sanitizer.py (100%) rename {tools/build => build}/graphs/source.py (98%) rename {tools/build => build}/main.py (99%) delete mode 100644 build/mutation/README.md delete mode 100644 build/mutation/mull.yml delete mode 100644 build/mutation/run.sh delete mode 100644 build/mutation/validate_report.py rename {tools/build => build}/pyproject.toml (100%) rename {tools/build => build}/templates/analysis.ps1 (100%) rename {tools/build => build}/templates/argv.json (100%) rename {tools/build => build}/templates/base.json (100%) rename {tools/build => build}/templates/catch2-test.ps1 (100%) rename {tools/build => build}/templates/clang-command.ps1 (100%) rename {tools/build => build}/templates/clang-format.ps1 (100%) rename {tools/build => build}/templates/clang-tidy.ps1 (100%) rename {tools/build => build}/templates/contract.ps1 (100%) rename {tools/build => build}/templates/coverage-corpus-test.ps1 (100%) rename {tools/build => build}/templates/coverage-merge.ps1 (100%) rename {tools/build => build}/templates/coverage-report.ps1 (100%) rename {tools/build => build}/templates/coverage-test.ps1 (100%) rename {tools/build => build}/templates/cppcheck.ps1 (100%) rename {tools/build => build}/templates/fuzz-build.ps1 (100%) rename {tools/build => build}/templates/fuzz-common.ps1 (100%) rename {tools/build => build}/templates/fuzz-gate.ps1 (100%) rename {tools/build => build}/templates/fuzz-replay.ps1 (100%) rename {tools/build => build}/templates/fuzz-run.ps1 (100%) rename {tools/build => build}/templates/instrumented-build.ps1 (100%) rename {tools/build => build}/templates/msbuild.ps1 (100%) rename {tools/build => build}/templates/msvc-analyze.ps1 (100%) rename {tools/build => build}/templates/native-build.ps1 (100%) rename {tools/build => build}/templates/native-corpus-test.ps1 (100%) rename {tools/build => build}/templates/native-test.ps1 (100%) rename {tools/build => build}/templates/psscriptanalyzer.ps1 (100%) rename {tools/build => build}/templates/pwsh.ps1 (100%) rename {tools/build => build}/templates/sanitizer-test.ps1 (100%) rename {tools/build => build}/templates/script.json (100%) rename {tools/build => build}/templates/selected-compile.ps1 (100%) rename {tools/build => build}/templates/source-dependencies.ps1 (100%) rename {tools/build => build}/templates/vcpkg.ps1 (100%) delete mode 100644 build/tests/mutation-ci-contract.Tests.ps1 rename {tools/build => build}/tests/test_analysis_graph.py (100%) rename {tools/build => build}/tests/test_audit_graph.py (99%) rename {tools/build => build}/tests/test_binary_audit.py (100%) rename {tools/build => build}/tests/test_clang_dependencies.py (100%) rename {tools/build => build}/tests/test_clean.py (100%) rename {tools/build => build}/tests/test_common.py (100%) rename {tools/build => build}/tests/test_core_coverage.py (100%) rename {tools/build => build}/tests/test_coverage_config.py (100%) rename {tools/build => build}/tests/test_cpp_coverage_graph.py (99%) rename {tools/build => build}/tests/test_doctor.py (100%) rename {tools/build => build}/tests/test_driver.py (100%) rename {tools/build => build}/tests/test_execute.py (100%) rename {tools/build => build}/tests/test_fuzz_graph.py (100%) rename {tools/build => build}/tests/test_graph.py (100%) rename {tools/build => build}/tests/test_graph_main_coverage.py (100%) rename {tools/build => build}/tests/test_host.py (100%) rename {tools/build => build}/tests/test_instrumented_graph.py (100%) rename {tools/build => build}/tests/test_leak_graph.py (100%) rename {tools/build => build}/tests/test_main.py (98%) rename {tools/build => build}/tests/test_msbuild_contracts.py (98%) delete mode 100644 build/tests/test_mutation_report.py rename {tools/build => build}/tests/test_native_graph.py (99%) rename {tools/build => build}/tests/test_node.py (100%) rename {tools/build => build}/tests/test_package_graph.py (100%) rename {tools/build => build}/tests/test_paths.py (100%) rename {tools/build => build}/tests/test_python_coverage_graph.py (91%) rename {tools/build => build}/tests/test_quality_tools.py (100%) rename {tools/build => build}/tests/test_recipe.py (100%) rename {tools/build => build}/tests/test_render.py (100%) rename {tools/build => build}/tests/test_runtime.py (100%) rename {tools/build => build}/tests/test_sanitizer_graph.py (99%) rename {tools/build => build}/tests/test_sarif.py (100%) rename {tools/build => build}/tests/test_sign.py (100%) rename {tools/build => build}/tests/test_source_graph.py (98%) rename {tools/build => build}/tests/test_source_tools.py (100%) rename {tools/build => build}/tests/test_store.py (100%) rename {tools/build => build}/tests/test_toolchain.py (100%) rename {tools/build => build}/tests/test_vcpkg_template.py (100%) rename {tools/build => build}/tests/test_windows_job.py (100%) rename {tools/build => build}/tests/test_windows_process.py (100%) rename {tools/build => build}/tests/test_workflow_contract.py (95%) rename {tools/build => build}/uv.lock (100%) delete mode 100644 src/tests/mutation/main.cpp diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b15df2a..5c1eb4c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: uses: astral-sh/setup-uv@v8 with: enable-cache: true - cache-dependency-glob: tools/build/uv.lock + cache-dependency-glob: build/uv.lock - name: Cache vcpkg binaries uses: actions/cache@v5 @@ -42,7 +42,7 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - uv sync --project tools/build --frozen + uv sync --project build --frozen - name: Provision external verification tools shell: pwsh diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml deleted file mode 100644 index 3c0711d..0000000 --- a/.github/workflows/mutation.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Mutation testing - -on: - push: - branches: [master] - paths: - - '.github/workflows/mutation.yml' - - 'build/mutation/**' - - 'build/tests/test_mutation_report.py' - - 'src/modules/renpy/pickle.*' - - 'src/tests/unit/pickle.cpp' - - 'src/tests/mutation/**' - - 'vcpkg.json' - pull_request: - branches: [master] - paths: - - '.github/workflows/mutation.yml' - - 'build/mutation/**' - - 'build/tests/test_mutation_report.py' - - 'src/modules/renpy/pickle.*' - - 'src/tests/unit/pickle.cpp' - - 'src/tests/mutation/**' - - 'vcpkg.json' - schedule: - - cron: '41 19 * * 6' - workflow_dispatch: - -concurrency: - group: mutation-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - MULL_VERSION: '0.34.0' - MULL_LLVM: '19' - MULL_REPOSITORY_FINGERPRINT: '6975C1E8A078A727081F4B7541DB35380DE6BD6F' - VCPKG_COMMIT: '9e593bb18ea69cc5095e012465dcd675a822ed0d' - VCPKG_INSTALLED_DIR: ${{ github.workspace }}/.artifacts/vcpkg-installed - -jobs: - pickle-core: - name: Portable pickle core (Mull) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Check out ObserverModules - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Test mutation-report enforcement - shell: bash - run: python3 -m unittest build.tests.test_mutation_report -v - - - name: Check out pinned vcpkg baseline - uses: actions/checkout@v6 - with: - repository: microsoft/vcpkg - ref: ${{ env.VCPKG_COMMIT }} - path: .artifacts/vcpkg-src - persist-credentials: false - - - name: Install pinned Mull toolchain - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends ca-certificates curl gnupg - - key_file="${RUNNER_TEMP}/mull-project.asc" - keyring_file="${RUNNER_TEMP}/mull-project.gpg" - curl --fail --location --proto '=https' --tlsv1.2 \ - 'https://dl.cloudsmith.io/public/mull-project/mull-stable/gpg.41DB35380DE6BD6F.key' \ - --output "${key_file}" - actual_fingerprint="$(gpg --show-keys --with-colons "${key_file}" | awk -F: '$1 == "fpr" { print toupper($10); exit }')" - test "${actual_fingerprint}" = "${MULL_REPOSITORY_FINGERPRINT}" - gpg --batch --yes --dearmor --output "${keyring_file}" "${key_file}" - sudo install -o root -g root -m 0644 "${keyring_file}" /usr/share/keyrings/mull-project.gpg - echo 'deb [signed-by=/usr/share/keyrings/mull-project.gpg] https://dl.cloudsmith.io/public/mull-project/mull-stable/deb/ubuntu noble main' \ - | sudo tee /etc/apt/sources.list.d/mull-project.list >/dev/null - - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - "clang-${MULL_LLVM}" \ - "mull-${MULL_LLVM}=${MULL_VERSION}" - "mull-runner-${MULL_LLVM}" --version - "clang++-${MULL_LLVM}" --version - - - name: Restore pinned Catch2 dependency - shell: bash - run: | - set -euo pipefail - .artifacts/vcpkg-src/bootstrap-vcpkg.sh -disableMetrics - .artifacts/vcpkg-src/vcpkg install catch2:x64-linux \ - --x-install-root="${VCPKG_INSTALLED_DIR}" \ - --clean-after-build - - - name: Run portable mutation gate - id: mutation - continue-on-error: true - shell: bash - run: | - mkdir -p .artifacts/reports/mutation - bash build/mutation/run.sh > .artifacts/reports/mutation/ci.log 2>&1 - - - name: Archive mutation evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: mutation-pickle-core - path: .artifacts/reports/mutation - if-no-files-found: error - retention-days: 30 - - - name: Enforce mutation gate - if: steps.mutation.outcome == 'failure' - shell: bash - run: exit 1 diff --git a/.gitignore b/.gitignore index 03bf99b..3c5aac7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ -/.artifacts /out/ -/.coverage* -/tools/build/.venv/ -/tools/build/.uv-cache/ -/tools/build/.coverage* +/build/.venv/ +/build/.uv-cache/ /.idea/ diff --git a/AGENTS.md b/AGENTS.md index 223c525..44ca438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,7 @@ This project uses C++23 and follows the high-assurance engineering policy in - Treat archive bytes, metadata, paths, counts, offsets, sizes, and callback behavior as untrusted input. Validate before use, use checked arithmetic before narrowing/allocation/seeking, impose explicit resource and iteration bounds, guarantee loop progress, and avoid input-driven recursion unless a strict depth limit is enforced. -- Express security-relevant condition combinations as executable, data-driven decision-table tests. Mutation testing - of first-party parser/application logic is a mandatory test-quality gate; a surviving non-equivalent mutant is a - test defect, not an acceptable score reduction. +- Express security-relevant condition combinations as executable, data-driven decision-table tests. - Keep functions cohesive, control flow reviewable, ownership explicit, and preprocessor use minimal. Avoid magic numbers, hidden global state, duplicated policy, and speculative abstraction. KISS and DRY remain subordinate to clear boundaries and independently testable behavior. diff --git a/README.md b/README.md index 48e8801..1afda70 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ No IDE, Visual Studio developer prompt, global vcpkg integration, or repository- From a normal Windows console: ```powershell -uv sync --project tools/build --frozen +uv sync --project build --frozen .\build.ps1 doctor .\build.ps1 build -Arch all -Config Release .\build.ps1 test -Arch x86,x64 -Config Debug diff --git a/build.ps1 b/build.ps1 index e69561a..9daa2e1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$buildProject = Join-Path $PSScriptRoot 'tools\build' +$buildProject = Join-Path $PSScriptRoot 'build' $env:UV_CACHE_DIR = Join-Path $buildProject '.uv-cache' & uv run --project $buildProject --frozen --no-sync python (Join-Path $buildProject 'main.py') @args exit $LASTEXITCODE diff --git a/build/ObserverProject.props b/build/ObserverProject.props index 2625e00..1abcf68 100644 --- a/build/ObserverProject.props +++ b/build/ObserverProject.props @@ -9,7 +9,7 @@ observer-$(PlatformMoniker)-windows-static observer-$(PlatformMoniker)-windows-static-asan true - + false false diff --git a/tools/build/core/__init__.py b/build/core/__init__.py similarity index 100% rename from tools/build/core/__init__.py rename to build/core/__init__.py diff --git a/tools/build/core/binary_audit.py b/build/core/binary_audit.py similarity index 100% rename from tools/build/core/binary_audit.py rename to build/core/binary_audit.py diff --git a/tools/build/core/clang_dependencies.py b/build/core/clang_dependencies.py similarity index 100% rename from tools/build/core/clang_dependencies.py rename to build/core/clang_dependencies.py diff --git a/tools/build/core/clean.py b/build/core/clean.py similarity index 100% rename from tools/build/core/clean.py rename to build/core/clean.py diff --git a/tools/build/core/cpp_coverage.py b/build/core/cpp_coverage.py similarity index 100% rename from tools/build/core/cpp_coverage.py rename to build/core/cpp_coverage.py diff --git a/tools/build/core/doctor.py b/build/core/doctor.py similarity index 100% rename from tools/build/core/doctor.py rename to build/core/doctor.py diff --git a/tools/build/core/execute.py b/build/core/execute.py similarity index 100% rename from tools/build/core/execute.py rename to build/core/execute.py diff --git a/tools/build/core/graph.py b/build/core/graph.py similarity index 100% rename from tools/build/core/graph.py rename to build/core/graph.py diff --git a/tools/build/core/host.py b/build/core/host.py similarity index 100% rename from tools/build/core/host.py rename to build/core/host.py diff --git a/tools/build/core/leak.py b/build/core/leak.py similarity index 100% rename from tools/build/core/leak.py rename to build/core/leak.py diff --git a/tools/build/core/node.py b/build/core/node.py similarity index 100% rename from tools/build/core/node.py rename to build/core/node.py diff --git a/tools/build/core/package.py b/build/core/package.py similarity index 100% rename from tools/build/core/package.py rename to build/core/package.py diff --git a/tools/build/core/paths.py b/build/core/paths.py similarity index 100% rename from tools/build/core/paths.py rename to build/core/paths.py diff --git a/tools/build/core/python_coverage.py b/build/core/python_coverage.py similarity index 100% rename from tools/build/core/python_coverage.py rename to build/core/python_coverage.py diff --git a/tools/build/core/quality_tools.py b/build/core/quality_tools.py similarity index 100% rename from tools/build/core/quality_tools.py rename to build/core/quality_tools.py diff --git a/tools/build/core/recipe.py b/build/core/recipe.py similarity index 100% rename from tools/build/core/recipe.py rename to build/core/recipe.py diff --git a/tools/build/core/render.py b/build/core/render.py similarity index 100% rename from tools/build/core/render.py rename to build/core/render.py diff --git a/tools/build/core/runtime.py b/build/core/runtime.py similarity index 100% rename from tools/build/core/runtime.py rename to build/core/runtime.py diff --git a/tools/build/core/sanitizer.py b/build/core/sanitizer.py similarity index 100% rename from tools/build/core/sanitizer.py rename to build/core/sanitizer.py diff --git a/tools/build/core/sarif.py b/build/core/sarif.py similarity index 100% rename from tools/build/core/sarif.py rename to build/core/sarif.py diff --git a/tools/build/core/sign.py b/build/core/sign.py similarity index 100% rename from tools/build/core/sign.py rename to build/core/sign.py diff --git a/tools/build/core/source_tools.py b/build/core/source_tools.py similarity index 100% rename from tools/build/core/source_tools.py rename to build/core/source_tools.py diff --git a/tools/build/core/store.py b/build/core/store.py similarity index 100% rename from tools/build/core/store.py rename to build/core/store.py diff --git a/tools/build/core/toolchain.py b/build/core/toolchain.py similarity index 100% rename from tools/build/core/toolchain.py rename to build/core/toolchain.py diff --git a/tools/build/core/windows_job.py b/build/core/windows_job.py similarity index 100% rename from tools/build/core/windows_job.py rename to build/core/windows_job.py diff --git a/tools/build/core/windows_process.py b/build/core/windows_process.py similarity index 100% rename from tools/build/core/windows_process.py rename to build/core/windows_process.py diff --git a/tools/build/driver.py b/build/driver.py similarity index 100% rename from tools/build/driver.py rename to build/driver.py diff --git a/tools/build/graphs/__init__.py b/build/graphs/__init__.py similarity index 100% rename from tools/build/graphs/__init__.py rename to build/graphs/__init__.py diff --git a/tools/build/graphs/analysis.py b/build/graphs/analysis.py similarity index 100% rename from tools/build/graphs/analysis.py rename to build/graphs/analysis.py diff --git a/tools/build/graphs/audit.py b/build/graphs/audit.py similarity index 100% rename from tools/build/graphs/audit.py rename to build/graphs/audit.py diff --git a/tools/build/graphs/common.py b/build/graphs/common.py similarity index 98% rename from tools/build/graphs/common.py rename to build/graphs/common.py index f5099b0..6e80fc3 100644 --- a/tools/build/graphs/common.py +++ b/build/graphs/common.py @@ -170,7 +170,7 @@ def python_action(factory: NodeFactory, name: str, module: str, arguments: tuple source = BUILD_ROOT.joinpath(*module.split(".")).with_suffix(".py") return factory.make( "argv.json", name, pool, {"argv": (executable, "-m", module) + arguments}, - files={source.relative_to(BUILD_ROOT.parent.parent).as_posix(): source.read_bytes()} | dict(files or {}), + files={source.relative_to(BUILD_ROOT.parent).as_posix(): source.read_bytes()} | dict(files or {}), dependencies=dependencies, identity=dict(identity or {}) | {"python": sys.version, "python_executable": executable}, config={"action": arguments[0], "platform": "windows"} | dict(config or {}), diff --git a/tools/build/graphs/coverage.py b/build/graphs/coverage.py similarity index 100% rename from tools/build/graphs/coverage.py rename to build/graphs/coverage.py diff --git a/tools/build/graphs/fuzz.py b/build/graphs/fuzz.py similarity index 100% rename from tools/build/graphs/fuzz.py rename to build/graphs/fuzz.py diff --git a/tools/build/graphs/instrumented.py b/build/graphs/instrumented.py similarity index 100% rename from tools/build/graphs/instrumented.py rename to build/graphs/instrumented.py diff --git a/tools/build/graphs/leak.py b/build/graphs/leak.py similarity index 100% rename from tools/build/graphs/leak.py rename to build/graphs/leak.py diff --git a/tools/build/graphs/native.py b/build/graphs/native.py similarity index 100% rename from tools/build/graphs/native.py rename to build/graphs/native.py diff --git a/tools/build/graphs/package.py b/build/graphs/package.py similarity index 100% rename from tools/build/graphs/package.py rename to build/graphs/package.py diff --git a/tools/build/graphs/python_coverage.py b/build/graphs/python_coverage.py similarity index 98% rename from tools/build/graphs/python_coverage.py rename to build/graphs/python_coverage.py index 5c289c7..86f5536 100644 --- a/tools/build/graphs/python_coverage.py +++ b/build/graphs/python_coverage.py @@ -39,7 +39,7 @@ def python_coverage_graph(repository: Path) -> Graph: """Build one demand gate using only the repository-local pinned coverage executable.""" root = repository.resolve(strict=True) - build_root = root / "tools/build" + build_root = root / "build" coverage = (build_root / ".venv/Scripts/coverage.exe").resolve(strict=True) if not coverage.is_file(): raise FileNotFoundError(f"project coverage executable is not a file: {coverage}") diff --git a/tools/build/graphs/sanitizer.py b/build/graphs/sanitizer.py similarity index 100% rename from tools/build/graphs/sanitizer.py rename to build/graphs/sanitizer.py diff --git a/tools/build/graphs/source.py b/build/graphs/source.py similarity index 98% rename from tools/build/graphs/source.py rename to build/graphs/source.py index 50da44e..a630b85 100644 --- a/tools/build/graphs/source.py +++ b/build/graphs/source.py @@ -21,7 +21,7 @@ "x64": ("win64", "_M_X64=100"), "arm64": ("win64", "_M_ARM64=1"), } -_IGNORED_DIRECTORIES = {".git", ".artifacts", ".venv", "__pycache__", "out"} +_IGNORED_DIRECTORIES = {".git", ".venv", "__pycache__", "out"} def _relative(repository: Path, path: Path) -> str: diff --git a/tools/build/main.py b/build/main.py similarity index 99% rename from tools/build/main.py rename to build/main.py index bac1bd6..b433dc6 100644 --- a/tools/build/main.py +++ b/build/main.py @@ -99,14 +99,14 @@ def _parser() -> argparse.ArgumentParser: doctor.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") for name, options in _COMMAND_OPTIONS.items(): command = commands.add_parser(name) - command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[2]) + command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) command.add_argument("-Jobs", "--jobs", type=_integer(1)) command.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") for option in options: flags, settings = _OPTIONS[option] command.add_argument(*flags, dest=option, **settings) clean = commands.add_parser("clean") - clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[2]) + clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) clean.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") flags, settings = _OPTIONS["clean_mode"] clean.add_argument(*flags, dest="clean_mode", **settings) diff --git a/build/mutation/README.md b/build/mutation/README.md deleted file mode 100644 index 1568209..0000000 --- a/build/mutation/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Portable mutation gate - -This directory contains the Linux-only mutation-test slice for the already portable -Ren'Py Pickle parser. It is intentionally outside the shipped build graph: - -- release DLLs remain MSVC/MSBuild-built Windows binaries; -- repository CMake is not introduced; -- `run.sh` compiles only `pickle.cpp`, its existing Catch2 unit tests, and the - platform-neutral test entry point; -- Mull is filtered to the first-party `pickle.cpp` implementation, so Catch2, - test code, and vcpkg sources are not mutation targets. - -The CI workflow installs exact Mull `0.34.0` for LLVM 19 from its signed repository, -checks the repository-key fingerprint before installation, and checks out vcpkg at -the repository manifest baseline. Local tool installation is not performed by any -repository command and still requires owner approval. - -Mull runs in strict mode with a mutation-score threshold of 100. The separate -`validate_report.py` gate also rejects an empty report and accepts only `Killed` -statuses. This closes the otherwise misleading case where a run with no discovered -mutants can report an infinite score. Mutation Testing Elements JSON and the full -CI log are retained as workflow artifacts before the job enforces failure. - -The Windows development host can validate the report policy and static workflow -contract without Mull: - -```powershell -uv run --no-python-downloads --offline --no-config --python 3.14.6 ` - python -m unittest build.tests.test_mutation_report -v -pwsh -NoProfile -File build/tests/mutation-ci-contract.Tests.ps1 -``` - -The first GitHub run remains the execution proof for the Ubuntu package repository, -Clang/Mull plugin ABI, direct Catch2 link, and actual surviving-mutant inventory. -Any surviving non-equivalent mutant is a test defect. Equivalent mutants require -explicit owner-reviewed disposition; they are not hidden by weakening the gate. diff --git a/build/mutation/mull.yml b/build/mutation/mull.yml deleted file mode 100644 index 5d9b4aa..0000000 --- a/build/mutation/mull.yml +++ /dev/null @@ -1,17 +0,0 @@ -quiet: false -silent: false -strict: true -includeNotCovered: false -dryRunEnabled: false -captureTestOutput: false -captureMutantOutput: false -mutators: - - cxx_default -includePaths: - - '.*/src/modules/renpy/pickle\.cpp$' -excludePaths: - - '.*/\.artifacts/.*' - - '.*/src/tests/.*' -parallelization: - workers: 2 - executionWorkers: 2 diff --git a/build/mutation/run.sh b/build/mutation/run.sh deleted file mode 100644 index 1913b28..0000000 --- a/build/mutation/run.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -repository_root="$(cd -- "${script_directory}/../.." && pwd -P)" -artifact_root="${repository_root}/.artifacts" -object_directory="${artifact_root}/mutation/obj" -report_directory="${artifact_root}/reports/mutation" -test_binary="${artifact_root}/mutation/pickle-tests" - -: "${MULL_LLVM:?MULL_LLVM must select the installed Clang and Mull major version}" -: "${VCPKG_INSTALLED_DIR:?VCPKG_INSTALLED_DIR must point to the pinned vcpkg install root}" - -compiler="clang++-${MULL_LLVM}" -mull_runner="mull-runner-${MULL_LLVM}" -mull_plugin="/usr/lib/mull-ir-frontend-${MULL_LLVM}" -catch_include="${VCPKG_INSTALLED_DIR}/x64-linux/include" -catch_library="${VCPKG_INSTALLED_DIR}/x64-linux/lib" -report_path="${report_directory}/pickle.json" - -for executable in "${compiler}" "${mull_runner}" python3; do - command -v "${executable}" >/dev/null -done -for required_file in \ - "${mull_plugin}" \ - "${catch_include}/catch2/catch_session.hpp" \ - "${catch_library}/libCatch2.a"; do - test -f "${required_file}" -done - -mkdir -p -- "${object_directory}" "${report_directory}" - -common_flags=( - -std=c++23 - -O0 - -g - -fno-omit-frame-pointer - -Wall - -Wextra - -Wpedantic - -Werror - -isystem "${catch_include}" -) - -export MULL_CONFIG="${repository_root}/build/mutation/mull.yml" - -"${compiler}" "${common_flags[@]}" \ - "-fpass-plugin=/usr/lib/mull-ir-frontend-${MULL_LLVM}" \ - -c "${repository_root}/src/modules/renpy/pickle.cpp" \ - -o "${object_directory}/pickle.o" -"${compiler}" "${common_flags[@]}" \ - -c "${repository_root}/src/tests/unit/pickle.cpp" \ - -o "${object_directory}/pickle-tests.o" -"${compiler}" "${common_flags[@]}" \ - -c "${repository_root}/src/tests/mutation/main.cpp" \ - -o "${object_directory}/main.o" -"${compiler}" \ - "${object_directory}/pickle.o" \ - "${object_directory}/pickle-tests.o" \ - "${object_directory}/main.o" \ - -L "${catch_library}" \ - -lCatch2 \ - -pthread \ - -o "${test_binary}" - -"${test_binary}" - -"${mull_runner}" \ - --workers 2 \ - --strict \ - --mutation-score-threshold 100 \ - --no-output \ - --reporters Elements \ - --report-dir "${report_directory}" \ - --report-name pickle \ - "${test_binary}" \ - 2>&1 | tee "${report_directory}/mull.log" - -python3 "${repository_root}/build/mutation/validate_report.py" "${report_path}" diff --git a/build/mutation/validate_report.py b/build/mutation/validate_report.py deleted file mode 100644 index 364cd97..0000000 --- a/build/mutation/validate_report.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Fail unless a Mutation Testing Elements report is non-empty and fully killed.""" - -import argparse -import json -import sys -from pathlib import Path - - -class ReportError(ValueError): - """The mutation report cannot prove the repository gate.""" - - -def _mutant_identity(filename, mutant): - mutant_id = mutant.get("id", "") - location = mutant.get("location", {}) - start = location.get("start", {}) if isinstance(location, dict) else {} - line = start.get("line") if isinstance(start, dict) else None - source = f"{filename}:{line}" if isinstance(line, int) else filename - return f"{source} [{mutant_id}]" - - -def validate(report): - if not isinstance(report, dict): - raise ReportError("report root must be a JSON object") - - files = report.get("files") - if not isinstance(files, dict): - raise ReportError("report field 'files' must be an object") - - reached = [] - for filename, file_result in files.items(): - if not isinstance(filename, str) or not isinstance(file_result, dict): - raise ReportError("every report file entry must have a string name and object value") - mutants = file_result.get("mutants") - if not isinstance(mutants, list): - raise ReportError(f"report file '{filename}' must contain a mutants array") - for mutant in mutants: - if not isinstance(mutant, dict): - raise ReportError(f"report file '{filename}' contains a non-object mutant") - status = mutant.get("status") - if not isinstance(status, str) or not status: - raise ReportError(f"mutant {_mutant_identity(filename, mutant)} has no valid status") - reached.append((filename, mutant, status)) - - if not reached: - raise ReportError("report contains no reached mutants") - - failures = [item for item in reached if item[2] != "Killed"] - if failures: - details = ", ".join( - f"{status}: {_mutant_identity(filename, mutant)}" - for filename, mutant, status in failures[:20] - ) - remainder = len(failures) - 20 - if remainder: - details += f", and {remainder} more" - raise ReportError(f"{len(failures)} reached mutants were not killed: {details}") - - return len(reached) - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("report", type=Path, help="Mutation Testing Elements JSON report") - arguments = parser.parse_args(argv) - - try: - with arguments.report.open(encoding="utf-8") as stream: - report = json.load(stream) - except (OSError, json.JSONDecodeError) as error: - print(f"mutation report is not valid JSON: {error}", file=sys.stderr) - return 1 - - try: - count = validate(report) - except ReportError as error: - print(f"mutation gate failed: {error}", file=sys.stderr) - return 1 - - print(f"mutation report: {count} reached mutants, all killed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/build/pyproject.toml b/build/pyproject.toml similarity index 100% rename from tools/build/pyproject.toml rename to build/pyproject.toml diff --git a/tools/build/templates/analysis.ps1 b/build/templates/analysis.ps1 similarity index 100% rename from tools/build/templates/analysis.ps1 rename to build/templates/analysis.ps1 diff --git a/tools/build/templates/argv.json b/build/templates/argv.json similarity index 100% rename from tools/build/templates/argv.json rename to build/templates/argv.json diff --git a/tools/build/templates/base.json b/build/templates/base.json similarity index 100% rename from tools/build/templates/base.json rename to build/templates/base.json diff --git a/tools/build/templates/catch2-test.ps1 b/build/templates/catch2-test.ps1 similarity index 100% rename from tools/build/templates/catch2-test.ps1 rename to build/templates/catch2-test.ps1 diff --git a/tools/build/templates/clang-command.ps1 b/build/templates/clang-command.ps1 similarity index 100% rename from tools/build/templates/clang-command.ps1 rename to build/templates/clang-command.ps1 diff --git a/tools/build/templates/clang-format.ps1 b/build/templates/clang-format.ps1 similarity index 100% rename from tools/build/templates/clang-format.ps1 rename to build/templates/clang-format.ps1 diff --git a/tools/build/templates/clang-tidy.ps1 b/build/templates/clang-tidy.ps1 similarity index 100% rename from tools/build/templates/clang-tidy.ps1 rename to build/templates/clang-tidy.ps1 diff --git a/tools/build/templates/contract.ps1 b/build/templates/contract.ps1 similarity index 100% rename from tools/build/templates/contract.ps1 rename to build/templates/contract.ps1 diff --git a/tools/build/templates/coverage-corpus-test.ps1 b/build/templates/coverage-corpus-test.ps1 similarity index 100% rename from tools/build/templates/coverage-corpus-test.ps1 rename to build/templates/coverage-corpus-test.ps1 diff --git a/tools/build/templates/coverage-merge.ps1 b/build/templates/coverage-merge.ps1 similarity index 100% rename from tools/build/templates/coverage-merge.ps1 rename to build/templates/coverage-merge.ps1 diff --git a/tools/build/templates/coverage-report.ps1 b/build/templates/coverage-report.ps1 similarity index 100% rename from tools/build/templates/coverage-report.ps1 rename to build/templates/coverage-report.ps1 diff --git a/tools/build/templates/coverage-test.ps1 b/build/templates/coverage-test.ps1 similarity index 100% rename from tools/build/templates/coverage-test.ps1 rename to build/templates/coverage-test.ps1 diff --git a/tools/build/templates/cppcheck.ps1 b/build/templates/cppcheck.ps1 similarity index 100% rename from tools/build/templates/cppcheck.ps1 rename to build/templates/cppcheck.ps1 diff --git a/tools/build/templates/fuzz-build.ps1 b/build/templates/fuzz-build.ps1 similarity index 100% rename from tools/build/templates/fuzz-build.ps1 rename to build/templates/fuzz-build.ps1 diff --git a/tools/build/templates/fuzz-common.ps1 b/build/templates/fuzz-common.ps1 similarity index 100% rename from tools/build/templates/fuzz-common.ps1 rename to build/templates/fuzz-common.ps1 diff --git a/tools/build/templates/fuzz-gate.ps1 b/build/templates/fuzz-gate.ps1 similarity index 100% rename from tools/build/templates/fuzz-gate.ps1 rename to build/templates/fuzz-gate.ps1 diff --git a/tools/build/templates/fuzz-replay.ps1 b/build/templates/fuzz-replay.ps1 similarity index 100% rename from tools/build/templates/fuzz-replay.ps1 rename to build/templates/fuzz-replay.ps1 diff --git a/tools/build/templates/fuzz-run.ps1 b/build/templates/fuzz-run.ps1 similarity index 100% rename from tools/build/templates/fuzz-run.ps1 rename to build/templates/fuzz-run.ps1 diff --git a/tools/build/templates/instrumented-build.ps1 b/build/templates/instrumented-build.ps1 similarity index 100% rename from tools/build/templates/instrumented-build.ps1 rename to build/templates/instrumented-build.ps1 diff --git a/tools/build/templates/msbuild.ps1 b/build/templates/msbuild.ps1 similarity index 100% rename from tools/build/templates/msbuild.ps1 rename to build/templates/msbuild.ps1 diff --git a/tools/build/templates/msvc-analyze.ps1 b/build/templates/msvc-analyze.ps1 similarity index 100% rename from tools/build/templates/msvc-analyze.ps1 rename to build/templates/msvc-analyze.ps1 diff --git a/tools/build/templates/native-build.ps1 b/build/templates/native-build.ps1 similarity index 100% rename from tools/build/templates/native-build.ps1 rename to build/templates/native-build.ps1 diff --git a/tools/build/templates/native-corpus-test.ps1 b/build/templates/native-corpus-test.ps1 similarity index 100% rename from tools/build/templates/native-corpus-test.ps1 rename to build/templates/native-corpus-test.ps1 diff --git a/tools/build/templates/native-test.ps1 b/build/templates/native-test.ps1 similarity index 100% rename from tools/build/templates/native-test.ps1 rename to build/templates/native-test.ps1 diff --git a/tools/build/templates/psscriptanalyzer.ps1 b/build/templates/psscriptanalyzer.ps1 similarity index 100% rename from tools/build/templates/psscriptanalyzer.ps1 rename to build/templates/psscriptanalyzer.ps1 diff --git a/tools/build/templates/pwsh.ps1 b/build/templates/pwsh.ps1 similarity index 100% rename from tools/build/templates/pwsh.ps1 rename to build/templates/pwsh.ps1 diff --git a/tools/build/templates/sanitizer-test.ps1 b/build/templates/sanitizer-test.ps1 similarity index 100% rename from tools/build/templates/sanitizer-test.ps1 rename to build/templates/sanitizer-test.ps1 diff --git a/tools/build/templates/script.json b/build/templates/script.json similarity index 100% rename from tools/build/templates/script.json rename to build/templates/script.json diff --git a/tools/build/templates/selected-compile.ps1 b/build/templates/selected-compile.ps1 similarity index 100% rename from tools/build/templates/selected-compile.ps1 rename to build/templates/selected-compile.ps1 diff --git a/tools/build/templates/source-dependencies.ps1 b/build/templates/source-dependencies.ps1 similarity index 100% rename from tools/build/templates/source-dependencies.ps1 rename to build/templates/source-dependencies.ps1 diff --git a/tools/build/templates/vcpkg.ps1 b/build/templates/vcpkg.ps1 similarity index 100% rename from tools/build/templates/vcpkg.ps1 rename to build/templates/vcpkg.ps1 diff --git a/build/tests/mutation-ci-contract.Tests.ps1 b/build/tests/mutation-ci-contract.Tests.ps1 deleted file mode 100644 index 0d84b0f..0000000 --- a/build/tests/mutation-ci-contract.Tests.ps1 +++ /dev/null @@ -1,128 +0,0 @@ -#requires -Version 7.4 - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$workflowPath = Join-Path $repositoryRoot '.github\workflows\mutation.yml' -$runnerPath = Join-Path $repositoryRoot 'build\mutation\run.sh' -$configPath = Join-Path $repositoryRoot 'build\mutation\mull.yml' -$mainPath = Join-Path $repositoryRoot 'src\tests\mutation\main.cpp' - -foreach ($requiredPath in @($workflowPath, $runnerPath, $configPath, $mainPath)) { - if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { - throw "Mutation CI contract file is missing: $requiredPath" - } -} - -$workflow = Get-Content -Raw -LiteralPath $workflowPath -$runner = Get-Content -Raw -LiteralPath $runnerPath -$config = Get-Content -Raw -LiteralPath $configPath -$portableMain = Get-Content -Raw -LiteralPath $mainPath - -foreach ($trigger in @('push:', 'pull_request:', 'schedule:', 'workflow_dispatch:')) { - if ($workflow -notmatch [regex]::Escape($trigger)) { - throw "Mutation workflow must expose the '$trigger' trigger." - } -} -if ($workflow -notmatch 'runs-on:\s*ubuntu-24\.04') { - throw 'Mutation testing must run on the pinned Ubuntu 24.04 image.' -} -if ($workflow -notmatch '(?ms)^permissions:\s*\r?\n\s+contents:\s*read\s*$') { - throw 'Mutation workflow must declare least-privilege contents:read permissions.' -} -if ($workflow -match 'security-events:\s*write|checks:\s*write|contents:\s*write') { - throw 'Mutation workflow must not request write permissions.' -} - -$expectedPins = @( - "MULL_VERSION: '0.34.0'", - "MULL_LLVM: '19'", - "VCPKG_COMMIT: '9e593bb18ea69cc5095e012465dcd675a822ed0d'", - "MULL_REPOSITORY_FINGERPRINT: '6975C1E8A078A727081F4B7541DB35380DE6BD6F'" -) -foreach ($pin in $expectedPins) { - if (-not $workflow.Contains($pin)) { - throw "Mutation workflow is missing the exact dependency pin: $pin" - } -} -if ($workflow -match '(?m)curl[^\r\n]*\|\s*(sudo\s+)?(bash|sh)') { - throw 'Mutation workflow must not pipe downloaded content into a shell.' -} -if ($workflow -notmatch 'gpg\s+--show-keys\s+--with-colons' -or $workflow -notmatch 'MULL_REPOSITORY_FINGERPRINT') { - throw 'Mutation workflow must verify the repository signing-key fingerprint before installation.' -} -if ($workflow -notmatch 'mull-\$\{MULL_LLVM\}=\$\{MULL_VERSION\}') { - throw 'Mutation workflow must install the exact Mull package version.' -} -if ($workflow -notmatch 'repository:\s*microsoft/vcpkg' -or $workflow -notmatch 'ref:\s*\$\{\{ env\.VCPKG_COMMIT \}\}') { - throw 'Mutation workflow must obtain vcpkg at the manifest baseline commit.' -} -if ( - -not $workflow.Contains("- 'build/tests/test_mutation_report.py'") -or - -not $workflow.Contains('python3 -m unittest build.tests.test_mutation_report -v') -) { - throw 'Mutation workflow must run the report-validator regression suite when that suite changes.' -} - -if ($workflow -notmatch '(?ms)id:\s*mutation.*?continue-on-error:\s*true') { - throw 'Mutation execution must retain control so reports can be archived on failure.' -} -if ( - -not $workflow.Contains('mkdir -p .artifacts/reports/mutation') -or - -not $workflow.Contains('> .artifacts/reports/mutation/ci.log 2>&1') -) { - throw 'Verbose mutation output must be retained as evidence, including infrastructure failures.' -} -if ($workflow -notmatch '(?ms)if:\s*always\(\).*?uses:\s*actions/upload-artifact@v7') { - throw 'Mutation evidence must always be archived.' -} -if ($workflow -notmatch '(?ms)if:\s*steps\.mutation\.outcome\s*==\s*''failure''.*?exit\s+1') { - throw 'Surviving mutants or mutation infrastructure failures must fail the workflow after archival.' -} - -if ($config -notmatch '(?m)^\s*-\s+cxx_default\s*$') { - throw 'The initial mutation scope must enable the stable cxx_default operator group.' -} -if ($config -notmatch 'src/modules/renpy/pickle\\\.cpp\$') { - throw 'The Mull include path must be confined to the portable first-party pickle implementation.' -} -if ($config -match 'includeNotCovered:\s*true|dryRunEnabled:\s*true') { - throw 'Mutation configuration must execute only reached mutants, never dry-run them.' -} - -foreach ($source in @( - 'src/modules/renpy/pickle.cpp', - 'src/tests/unit/pickle.cpp', - 'src/tests/mutation/main.cpp' -)) { - if (-not $runner.Contains($source)) { - throw "Portable mutation build must compile $source." - } -} -foreach ($requiredArgument in @( - '-fpass-plugin=/usr/lib/mull-ir-frontend-${MULL_LLVM}', - '--reporters Elements', - '--mutation-score-threshold 100', - '--strict', - '--no-output', - 'validate_report.py' -)) { - if (-not $runner.Contains($requiredArgument)) { - throw "Mutation runner is missing required argument: $requiredArgument" - } -} -if ($runner -notmatch '(?m)^set -euo pipefail$') { - throw 'Mutation runner must fail closed on command, variable, and pipeline errors.' -} -if ($runner -match '(?i)cmake|msbuild|\.\s*[/\\]build\.ps1') { - throw 'Portable mutation testing must not enter the Windows/MSBuild production graph.' -} -if ($runner -notmatch '(?m)^"\$\{test_binary\}"$') { - throw 'The unmutated portable test binary must pass before Mull runs.' -} -if ($portableMain -notmatch 'Catch::Session\(\)\.run\(argc, argv\)') { - throw 'The portable test entry point must run the existing Catch2 tests without Windows setup.' -} - -Write-Output 'Mutation CI static contract is valid.' diff --git a/tools/build/tests/test_analysis_graph.py b/build/tests/test_analysis_graph.py similarity index 100% rename from tools/build/tests/test_analysis_graph.py rename to build/tests/test_analysis_graph.py diff --git a/tools/build/tests/test_audit_graph.py b/build/tests/test_audit_graph.py similarity index 99% rename from tools/build/tests/test_audit_graph.py rename to build/tests/test_audit_graph.py index 8b952bb..632a415 100644 --- a/tools/build/tests/test_audit_graph.py +++ b/build/tests/test_audit_graph.py @@ -24,7 +24,7 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: name, hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), "build", - Command((str(Path("C:/tools/build.exe")),)), + Command((str(Path("C:/sdk/build.exe")),)), inputs, ) diff --git a/tools/build/tests/test_binary_audit.py b/build/tests/test_binary_audit.py similarity index 100% rename from tools/build/tests/test_binary_audit.py rename to build/tests/test_binary_audit.py diff --git a/tools/build/tests/test_clang_dependencies.py b/build/tests/test_clang_dependencies.py similarity index 100% rename from tools/build/tests/test_clang_dependencies.py rename to build/tests/test_clang_dependencies.py diff --git a/tools/build/tests/test_clean.py b/build/tests/test_clean.py similarity index 100% rename from tools/build/tests/test_clean.py rename to build/tests/test_clean.py diff --git a/tools/build/tests/test_common.py b/build/tests/test_common.py similarity index 100% rename from tools/build/tests/test_common.py rename to build/tests/test_common.py diff --git a/tools/build/tests/test_core_coverage.py b/build/tests/test_core_coverage.py similarity index 100% rename from tools/build/tests/test_core_coverage.py rename to build/tests/test_core_coverage.py diff --git a/tools/build/tests/test_coverage_config.py b/build/tests/test_coverage_config.py similarity index 100% rename from tools/build/tests/test_coverage_config.py rename to build/tests/test_coverage_config.py diff --git a/tools/build/tests/test_cpp_coverage_graph.py b/build/tests/test_cpp_coverage_graph.py similarity index 99% rename from tools/build/tests/test_cpp_coverage_graph.py rename to build/tests/test_cpp_coverage_graph.py index 8b8b497..6f63d83 100644 --- a/tools/build/tests/test_cpp_coverage_graph.py +++ b/build/tests/test_cpp_coverage_graph.py @@ -32,7 +32,7 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: name, hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), "build", - Command(("C:/tools/build.exe",)), + Command(("C:/sdk/build.exe",)), inputs, ) diff --git a/tools/build/tests/test_doctor.py b/build/tests/test_doctor.py similarity index 100% rename from tools/build/tests/test_doctor.py rename to build/tests/test_doctor.py diff --git a/tools/build/tests/test_driver.py b/build/tests/test_driver.py similarity index 100% rename from tools/build/tests/test_driver.py rename to build/tests/test_driver.py diff --git a/tools/build/tests/test_execute.py b/build/tests/test_execute.py similarity index 100% rename from tools/build/tests/test_execute.py rename to build/tests/test_execute.py diff --git a/tools/build/tests/test_fuzz_graph.py b/build/tests/test_fuzz_graph.py similarity index 100% rename from tools/build/tests/test_fuzz_graph.py rename to build/tests/test_fuzz_graph.py diff --git a/tools/build/tests/test_graph.py b/build/tests/test_graph.py similarity index 100% rename from tools/build/tests/test_graph.py rename to build/tests/test_graph.py diff --git a/tools/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py similarity index 100% rename from tools/build/tests/test_graph_main_coverage.py rename to build/tests/test_graph_main_coverage.py diff --git a/tools/build/tests/test_host.py b/build/tests/test_host.py similarity index 100% rename from tools/build/tests/test_host.py rename to build/tests/test_host.py diff --git a/tools/build/tests/test_instrumented_graph.py b/build/tests/test_instrumented_graph.py similarity index 100% rename from tools/build/tests/test_instrumented_graph.py rename to build/tests/test_instrumented_graph.py diff --git a/tools/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py similarity index 100% rename from tools/build/tests/test_leak_graph.py rename to build/tests/test_leak_graph.py diff --git a/tools/build/tests/test_main.py b/build/tests/test_main.py similarity index 98% rename from tools/build/tests/test_main.py rename to build/tests/test_main.py index 7615ee6..0842bb8 100644 --- a/tools/build/tests/test_main.py +++ b/build/tests/test_main.py @@ -236,10 +236,11 @@ def test_script_entry_point_uses_the_same_cli(self) -> None: doctor.assert_called_once_with(()) def test_root_powershell_entry_point_uses_frozen_project_environment(self) -> None: - script = (BUILD_ROOT.parents[1] / "build.ps1").read_text(encoding="utf-8") + script = (BUILD_ROOT.parent / "build.ps1").read_text(encoding="utf-8") self.assertIn("uv run --project $buildProject --frozen --no-sync", script) - self.assertIn("tools\\build", script) + self.assertIn("$PSScriptRoot 'build'", script) + self.assertNotIn("tools\\build", script) self.assertIn("exit $LASTEXITCODE", script) self.assertNotIn("build\\build.ps1", script) diff --git a/tools/build/tests/test_msbuild_contracts.py b/build/tests/test_msbuild_contracts.py similarity index 98% rename from tools/build/tests/test_msbuild_contracts.py rename to build/tests/test_msbuild_contracts.py index 4d34274..5647de4 100644 --- a/tools/build/tests/test_msbuild_contracts.py +++ b/build/tests/test_msbuild_contracts.py @@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET -REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}" diff --git a/build/tests/test_mutation_report.py b/build/tests/test_mutation_report.py deleted file mode 100644 index 86dfee6..0000000 --- a/build/tests/test_mutation_report.py +++ /dev/null @@ -1,92 +0,0 @@ -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -VALIDATOR = REPOSITORY_ROOT / "build" / "mutation" / "validate_report.py" - - -class MutationReportValidatorTests(unittest.TestCase): - def run_validator(self, report): - with tempfile.TemporaryDirectory() as directory: - report_path = Path(directory) / "mutation.json" - if isinstance(report, str): - report_path.write_text(report, encoding="utf-8") - else: - report_path.write_text(json.dumps(report), encoding="utf-8") - return subprocess.run( - [sys.executable, str(VALIDATOR), str(report_path)], - cwd=REPOSITORY_ROOT, - capture_output=True, - text=True, - check=False, - ) - - @staticmethod - def report(*mutants): - return { - "schemaVersion": "1.0", - "files": { - "src/modules/renpy/pickle.cpp": { - "language": "cpp", - "mutants": list(mutants), - } - }, - } - - def test_accepts_non_empty_report_when_every_mutant_is_killed(self): - result = self.run_validator( - self.report( - {"id": "1", "status": "Killed", "location": {"start": {"line": 10}}}, - {"id": "2", "status": "Killed", "location": {"start": {"line": 20}}}, - ) - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "mutation report: 2 reached mutants, all killed") - - def test_rejects_empty_report_instead_of_accepting_infinite_score(self): - result = self.run_validator(self.report()) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("no reached mutants", result.stderr) - - def test_rejects_surviving_mutant_with_actionable_identity(self): - result = self.run_validator( - self.report( - { - "id": "boundary-7", - "status": "Survived", - "location": {"start": {"line": 73}}, - } - ) - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("Survived", result.stderr) - self.assertIn("pickle.cpp:73", result.stderr) - self.assertIn("boundary-7", result.stderr) - - def test_rejects_non_killed_reached_statuses(self): - for status in ("NoCoverage", "Timeout", "RuntimeError", "Ignored"): - with self.subTest(status=status): - result = self.run_validator(self.report({"id": status, "status": status})) - self.assertNotEqual(result.returncode, 0) - self.assertIn(status, result.stderr) - - def test_rejects_malformed_json_and_malformed_mutants(self): - invalid_json = self.run_validator("not-json") - self.assertNotEqual(invalid_json.returncode, 0) - self.assertIn("valid JSON", invalid_json.stderr) - - invalid_mutant = self.run_validator(self.report({"id": "missing-status"})) - self.assertNotEqual(invalid_mutant.returncode, 0) - self.assertIn("status", invalid_mutant.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/build/tests/test_native_graph.py b/build/tests/test_native_graph.py similarity index 99% rename from tools/build/tests/test_native_graph.py rename to build/tests/test_native_graph.py index 5b32b74..b2727ab 100644 --- a/tools/build/tests/test_native_graph.py +++ b/build/tests/test_native_graph.py @@ -29,7 +29,7 @@ def test_parallel_msvc_builds_do_not_depend_on_shared_compiler_pdb_server( self, ) -> None: root = ET.parse( - BUILD_ROOT.parents[1] / "build/ObserverProject.props" + BUILD_ROOT / "ObserverProject.props" ).getroot() debug_information = root.find( ".//{http://schemas.microsoft.com/developer/msbuild/2003}DebugInformationFormat" diff --git a/tools/build/tests/test_node.py b/build/tests/test_node.py similarity index 100% rename from tools/build/tests/test_node.py rename to build/tests/test_node.py diff --git a/tools/build/tests/test_package_graph.py b/build/tests/test_package_graph.py similarity index 100% rename from tools/build/tests/test_package_graph.py rename to build/tests/test_package_graph.py diff --git a/tools/build/tests/test_paths.py b/build/tests/test_paths.py similarity index 100% rename from tools/build/tests/test_paths.py rename to build/tests/test_paths.py diff --git a/tools/build/tests/test_python_coverage_graph.py b/build/tests/test_python_coverage_graph.py similarity index 91% rename from tools/build/tests/test_python_coverage_graph.py rename to build/tests/test_python_coverage_graph.py index 8fd1fec..a4fe6f3 100644 --- a/tools/build/tests/test_python_coverage_graph.py +++ b/build/tests/test_python_coverage_graph.py @@ -20,7 +20,7 @@ class PythonCoverageGraphTests(unittest.TestCase): def repository(self, root: Path, executable: bool = True) -> Path: - build = root / "tools/build" + build = root / "build" for relative, content in ( ("core/example.py", "VALUE = 1\n"), ("graphs/example.py", "VALUE = 2\n"), @@ -59,23 +59,23 @@ def test_single_gate_signs_all_first_party_tests_config_lock_and_exact_local_cov self.assertEqual( node.command.argv, (sys.executable, "-m", "core.python_coverage", - str(repository / "tools/build/.venv/Scripts/coverage.exe"), str(repository / "tools/build")), + str(repository / "build/.venv/Scripts/coverage.exe"), str(repository / "build")), ) - (repository / "tools/build/tests/test_example.py").write_text("changed\n", encoding="utf-8") + (repository / "build/tests/test_example.py").write_text("changed\n", encoding="utf-8") changed_test = python_coverage_graph(repository) - (repository / "tools/build/tests/test_example.py").write_text("pass\n", encoding="utf-8") - (repository / "tools/build/driver.py").write_text("changed\n", encoding="utf-8") + (repository / "build/tests/test_example.py").write_text("pass\n", encoding="utf-8") + (repository / "build/driver.py").write_text("changed\n", encoding="utf-8") changed_driver = python_coverage_graph(repository) - (repository / "tools/build/driver.py").write_text("VALUE = 4\n", encoding="utf-8") - config = repository / "tools/build/pyproject.toml" + (repository / "build/driver.py").write_text("VALUE = 4\n", encoding="utf-8") + config = repository / "build/pyproject.toml" original_config = config.read_text(encoding="utf-8") config.write_text(original_config + "\n# changed\n", encoding="utf-8") changed_config = python_coverage_graph(repository) config.write_text(original_config, encoding="utf-8") - (repository / "tools/build/.venv/Scripts/coverage.exe").write_bytes(b"changed-coverage") + (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"changed-coverage") changed_tool = python_coverage_graph(repository) - (repository / "tools/build/.venv/Scripts/coverage.exe").write_bytes(b"coverage-launcher") - (repository / "tools/build/.venv/Lib/site-packages/coverage/version.py").write_text( + (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"coverage-launcher") + (repository / "build/.venv/Lib/site-packages/coverage/version.py").write_text( "__version__ = 'changed'\n", encoding="utf-8" ) changed_package = python_coverage_graph(repository) @@ -90,14 +90,14 @@ def test_missing_or_non_file_project_coverage_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) missing = self.repository(root / "missing") - (missing / "tools/build/.venv/Scripts/coverage.exe").unlink() + (missing / "build/.venv/Scripts/coverage.exe").unlink() with self.assertRaises(FileNotFoundError): python_coverage_graph(missing) directory = self.repository(root / "directory", executable=False) with self.assertRaisesRegex(FileNotFoundError, "not a file"): python_coverage_graph(directory) bad_package = self.repository(root / "bad-package") - package = bad_package / "tools/build/.venv/Lib/site-packages/coverage" + package = bad_package / "build/.venv/Lib/site-packages/coverage" (package / "version.py").unlink() (package / "__pycache__/version.pyc").unlink() (package / "__pycache__").rmdir() diff --git a/tools/build/tests/test_quality_tools.py b/build/tests/test_quality_tools.py similarity index 100% rename from tools/build/tests/test_quality_tools.py rename to build/tests/test_quality_tools.py diff --git a/tools/build/tests/test_recipe.py b/build/tests/test_recipe.py similarity index 100% rename from tools/build/tests/test_recipe.py rename to build/tests/test_recipe.py diff --git a/tools/build/tests/test_render.py b/build/tests/test_render.py similarity index 100% rename from tools/build/tests/test_render.py rename to build/tests/test_render.py diff --git a/tools/build/tests/test_runtime.py b/build/tests/test_runtime.py similarity index 100% rename from tools/build/tests/test_runtime.py rename to build/tests/test_runtime.py diff --git a/tools/build/tests/test_sanitizer_graph.py b/build/tests/test_sanitizer_graph.py similarity index 99% rename from tools/build/tests/test_sanitizer_graph.py rename to build/tests/test_sanitizer_graph.py index d6c53e3..4f0a45e 100644 --- a/tools/build/tests/test_sanitizer_graph.py +++ b/build/tests/test_sanitizer_graph.py @@ -33,7 +33,7 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: name, hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), "build", - Command(("C:/tools/build.exe",)), + Command(("C:/sdk/build.exe",)), inputs, ) @@ -384,7 +384,7 @@ def test_complete_graph_discovers_and_builds_all_sanitizer_artifacts_itself(self self.assertEqual(shard.inputs.count(build.name), 1) def test_sanitizer_runtime_is_test_only_and_release_remains_static_mt(self) -> None: - root = ET.parse(BUILD_ROOT.parents[1] / "build/ObserverProject.props").getroot() + root = ET.parse(BUILD_ROOT / "ObserverProject.props").getroot() namespace = "{http://schemas.microsoft.com/developer/msbuild/2003}" runtime_libraries = root.findall(f".//{namespace}RuntimeLibrary") release = next( diff --git a/tools/build/tests/test_sarif.py b/build/tests/test_sarif.py similarity index 100% rename from tools/build/tests/test_sarif.py rename to build/tests/test_sarif.py diff --git a/tools/build/tests/test_sign.py b/build/tests/test_sign.py similarity index 100% rename from tools/build/tests/test_sign.py rename to build/tests/test_sign.py diff --git a/tools/build/tests/test_source_graph.py b/build/tests/test_source_graph.py similarity index 98% rename from tools/build/tests/test_source_graph.py rename to build/tests/test_source_graph.py index b5ca391..85ae48d 100644 --- a/tools/build/tests/test_source_graph.py +++ b/build/tests/test_source_graph.py @@ -9,7 +9,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] -REPOSITORY = BUILD_ROOT.parents[1] +REPOSITORY = BUILD_ROOT.parent sys.path.insert(0, str(BUILD_ROOT)) from graphs.source import SourceTools, _repository_files, source_checks # noqa: E402 @@ -49,7 +49,7 @@ def test_contract_inventory_excludes_transient_build_state(self) -> None: source = repository / "src/file.cpp" source.parent.mkdir() source.touch() - for relative in (".coverage", "tools/build/.coverage.agent"): + for relative in (".coverage", "build/.coverage.agent"): path = repository / relative path.parent.mkdir(parents=True, exist_ok=True) path.touch() @@ -166,7 +166,6 @@ def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(sel self.assertIn("'-D_M_IX86=600'", cppcheck_script) self.assertIn(f"'-I{include_dir}'", cppcheck_script) self.assertIn("'--suppress=*:out/cas/*-restore-vcpkg-*/out/*'", cppcheck_script) - self.assertNotIn(".artifacts/vcpkg_installed", cppcheck_script) self.assertIn('"--output-file=$outDir\\cppcheck.sarif"', cppcheck_script) self.assertNotIn("--error-exitcode", cppcheck_script) self.assertIn("Cppcheck did not produce cppcheck.sarif", cppcheck_script) diff --git a/tools/build/tests/test_source_tools.py b/build/tests/test_source_tools.py similarity index 100% rename from tools/build/tests/test_source_tools.py rename to build/tests/test_source_tools.py diff --git a/tools/build/tests/test_store.py b/build/tests/test_store.py similarity index 100% rename from tools/build/tests/test_store.py rename to build/tests/test_store.py diff --git a/tools/build/tests/test_toolchain.py b/build/tests/test_toolchain.py similarity index 100% rename from tools/build/tests/test_toolchain.py rename to build/tests/test_toolchain.py diff --git a/tools/build/tests/test_vcpkg_template.py b/build/tests/test_vcpkg_template.py similarity index 100% rename from tools/build/tests/test_vcpkg_template.py rename to build/tests/test_vcpkg_template.py diff --git a/tools/build/tests/test_windows_job.py b/build/tests/test_windows_job.py similarity index 100% rename from tools/build/tests/test_windows_job.py rename to build/tests/test_windows_job.py diff --git a/tools/build/tests/test_windows_process.py b/build/tests/test_windows_process.py similarity index 100% rename from tools/build/tests/test_windows_process.py rename to build/tests/test_windows_process.py diff --git a/tools/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py similarity index 95% rename from tools/build/tests/test_workflow_contract.py rename to build/tests/test_workflow_contract.py index 57238a1..7ba98bc 100644 --- a/tools/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -4,7 +4,7 @@ import unittest -REPOSITORY = Path(__file__).parents[3] +REPOSITORY = Path(__file__).parents[2] class WorkflowContractTests(unittest.TestCase): @@ -17,7 +17,6 @@ def test_main_ci_is_only_a_thin_public_verify_client(self) -> None: self.assertIn("./build.ps1 verify -Arch x64", workflow) self.assertNotIn("continue-on-error", workflow) for private_protocol in ( - ".artifacts", "upload-artifact", "download-artifact", "upload-sarif", diff --git a/tools/build/uv.lock b/build/uv.lock similarity index 100% rename from tools/build/uv.lock rename to build/uv.lock diff --git a/docs/build-system.md b/docs/build-system.md index 03e5937..08afdfc 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -21,10 +21,10 @@ orchestration and keeps MSBuild as the native compile/link backend. build.ps1 / build.cmd stable command-line entry point | v -tools/build/main.py command contract and graph composition +build/main.py command contract and graph composition | v -tools/build/graphs/* fine-grained content-addressed DAG +build/graphs/* fine-grained content-addressed DAG | v build/projects/*.vcxproj compile/link graph @@ -88,7 +88,7 @@ below `out/work`. deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A non-native runtime check is reported explicitly as deferred rather than falsely reported as executed. All gates are -designed to be runnable locally; the later WSL2 backend will supply Linux-only sanitizer and mutation capabilities. +designed to be runnable locally on the supported Windows host. The verify fuzz work covers all four format targets; `-FuzzSeconds` controls each bounded run. All graph families are merged into one executor. Ready nodes from builds, tests, analyzers, coverage, sanitizers, @@ -125,7 +125,7 @@ deprecated compatibility no-op and is rejected on `restore` itself. Developer tools are not library dependencies and are discovered by `doctor`: -- `uv` and the repository environment initialized once with `uv sync --project tools/build --frozen`; +- `uv` and the repository environment initialized once with `uv sync --project build --frozen`; - Visual Studio Build Tools 2022 with MSVC x86/x64 and ARM64 tools plus a Windows SDK; - PowerShell 7.4 or newer; - vcpkg; @@ -134,7 +134,7 @@ Developer tools are not library dependencies and are discovered by `doctor`: - PSScriptAnalyzer for PowerShell sources. Normal `build.ps1` commands use `uv --frozen --no-sync`: they neither resolve nor download Python packages while a -build is running. `tools/build/uv.lock` exact-pins Python 3.14.6 and the small runtime dependency set. +build is running. `build/uv.lock` exact-pins Python 3.14.6 and the small runtime dependency set. ## Compiler and static-analysis policy @@ -171,8 +171,7 @@ inspect any mandatory gate. The main GitHub workflow is therefore a thin client: it provisions an otherwise empty hosted runner, then invokes the same public `doctor` and bounded `verify` commands used locally. It contains no private gate graph, report parser, -artifact-path protocol, or release logic. The temporary mutation workflow remains separate only until its work moves -to the supported local WSL2 backend. +artifact-path protocol, or release logic. ### Future analysis backlog @@ -304,12 +303,8 @@ coordination lock and refuses to race active runs or node publishers. data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate that every target is the exact repository `out` layout and reject reparse points or unknown entries. -## Planned WSL2 backend - -After the portable parser core exists, the same Python graph/signing model will gain short POSIX-shell recipes and run -natively inside WSL2. Windows must not launch one `wsl.exe` per leaf. Platform, shell, and toolchain identity are signed, -so Linux and Windows outputs cannot alias. This local backend will add Mull mutation testing and the Linux -ASan/UBSan/LSan surface; shipping modules remain Windows/MSVC artifacts. +After the portable parser core is complete, revisit a separate local WSL2 workflow for Linux-only sanitizers and +test-quality experiments. It is not part of the current build graph; shipping modules remain Windows/MSVC artifacts. ## Release audit and packaging diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md index d4dd57b..4b10208 100644 --- a/docs/critical-software-methodology.md +++ b/docs/critical-software-methodology.md @@ -107,21 +107,18 @@ Every layer finds a different defect class; passing one does not substitute for 1. **Fast deterministic tests:** parser/unit tests, common archive-operation tests, and ABI contract tests. 2. **Structural coverage:** 100% LLVM line and branch coverage over first-party production code, plus review of tests that reach each branch. -3. **Mutation testing:** mutate first-party parser/application logic and require every non-equivalent reached mutant to - be killed. Surviving mutants are fixed with stronger behavioral tests; they are not hidden by lowering a percentage - threshold. Mutation reports are retained as local evidence. -4. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on x86, x64, and ARM64 where the runner is native. -5. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis. +3. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on x86, x64, and ARM64 where the runner is native. +4. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis. Diagnostics are fixed or narrowly justified, never globally muted. Additional services such as CodeQL may repeat or extend this evidence but cannot replace a local gate. -6. **Dynamic analysis:** MSVC AddressSanitizer where supported; clang-cl AddressSanitizer and +5. **Dynamic analysis:** MSVC AddressSanitizer where supported; clang-cl AddressSanitizer and UndefinedBehaviorSanitizer as independent diagnostic builds; UMDH across repeated real-DLL operations and repeated load/unload cycles. Peak private bytes and resource budgets are checked separately from leak growth. -7. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated +6. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated seed corpora, bounded input/resources, persisted crash artifacts, and regression tests for every confirmed defect. -8. **Binary and package assurance:** exact exports, forbidden imports, `/MT` runtime audit, BinSkim, PDB archive audit, +7. **Binary and package assurance:** exact exports, forbidden imports, `/MT` runtime audit, BinSkim, PDB archive audit, archive-content allowlists, and smoke tests of the packaged DLL bytes. -9. **Release evidence:** clean-checkout build, pinned dependencies, full gate results, hashes/artifacts, and a reviewed +8. **Release evidence:** clean-checkout build, pinned dependencies, full gate results, hashes/artifacts, and a reviewed decision/deviation log. Coverage and fuzzing must not catch allocation exhaustion or unexpected exceptions merely to keep running. A crash, @@ -162,9 +159,5 @@ covers the residual risk. memory ceilings per format, including whether callers may configure them. 3. **Shared ownership:** forbid `std::shared_ptr` entirely in first-party code unless an ADR is approved, or permit it with a local lifetime rationale? -4. **Mutation engine:** select and pin a practical engine. Mull is LLVM-based and produces machine-readable reports, - but does not provide a supported native-Windows workflow; the current leading design is to mutate the portable - parser core under Clang in the local WSL2 backend while keeping all shipped binaries MSVC-built and independently - tested. -5. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become +4. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become mandatory release gates. diff --git a/src/tests/mutation/main.cpp b/src/tests/mutation/main.cpp deleted file mode 100644 index d14f2b4..0000000 --- a/src/tests/mutation/main.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include - -int main(int argc, char *argv[]) -{ - return Catch::Session().run(argc, argv); -} From 32a80ba9bb3c422fabe59c0086a7153507531da1 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 17:21:45 +1000 Subject: [PATCH 07/17] Refactor build pipeline around IX-style DAG --- .github/workflows/main.yml | 98 +++++- .gitignore | 1 + AGENTS.md | 2 + build/core/clean.py | 2 +- build/core/execute.py | 52 ++- build/core/graph.py | 56 +++- build/core/leak.py | 28 +- build/core/node.py | 12 +- build/core/package.py | 5 +- build/core/paths.py | 15 +- build/core/recipe.py | 17 +- build/core/result_export.py | 289 ++++++++++++++++ build/core/store.py | 2 +- build/driver.py | 179 ++++++---- build/graphs/analysis.py | 331 +++++++++++-------- build/graphs/audit.py | 154 +++++---- build/graphs/common.py | 55 ++-- build/graphs/coverage.py | 128 ++++---- build/graphs/fuzz.py | 31 +- build/graphs/instrumented.py | 154 +++------ build/graphs/leak.py | 113 +++---- build/graphs/native.py | 129 ++------ build/graphs/package.py | 212 ++++++------ build/graphs/python_coverage.py | 15 +- build/graphs/sanitizer.py | 103 +++--- build/graphs/source.py | 72 +++- build/main.py | 98 +++--- build/templates/_output.ps1 | 5 + build/templates/base.json | 1 + build/templates/catch2-test.ps1 | 6 +- build/templates/clang-command.ps1 | 6 +- build/templates/clang-tidy.ps1 | 6 +- build/templates/cppcheck.ps1 | 2 +- build/templates/fuzz-build.ps1 | 6 +- build/templates/msvc-analyze.ps1 | 6 +- build/templates/sanitizer-test.ps1 | 6 +- build/templates/source-dependencies.ps1 | 6 +- build/templates/vcpkg.ps1 | 7 +- build/tests/test_analysis_graph.py | 139 ++++++-- build/tests/test_audit_graph.py | 175 +++++----- build/tests/test_clean.py | 21 +- build/tests/test_common.py | 26 +- build/tests/test_cpp_coverage_graph.py | 168 +++++----- build/tests/test_driver.py | 262 ++++++++++++--- build/tests/test_execute.py | 57 ++-- build/tests/test_fuzz_graph.py | 16 +- build/tests/test_graph.py | 63 +++- build/tests/test_graph_main_coverage.py | 17 - build/tests/test_instrumented_graph.py | 34 +- build/tests/test_leak_graph.py | 173 +++++----- build/tests/test_main.py | 48 ++- build/tests/test_native_graph.py | 17 + build/tests/test_node.py | 23 ++ build/tests/test_package_graph.py | 260 ++++++++------- build/tests/test_paths.py | 27 +- build/tests/test_python_coverage_graph.py | 11 + build/tests/test_recipe.py | 38 ++- build/tests/test_render.py | 58 +++- build/tests/test_result_export.py | 382 ++++++++++++++++++++++ build/tests/test_sanitizer_graph.py | 99 ++---- build/tests/test_source_graph.py | 59 +++- build/tests/test_store.py | 39 ++- build/tests/test_vcpkg_template.py | 5 +- build/tests/test_workflow_contract.py | 77 ++++- docs/build-system.md | 155 ++++++++- docs/critical-software-methodology.md | 3 +- 66 files changed, 3207 insertions(+), 1655 deletions(-) create mode 100644 build/core/result_export.py create mode 100644 build/templates/_output.ps1 create mode 100644 build/tests/test_result_export.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5c1eb4c..ebff77e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,11 +5,10 @@ on: branches: [master] pull_request: branches: [master] - workflow_dispatch: concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -19,26 +18,54 @@ env: VCPKG_DEFAULT_BINARY_CACHE: C:\vcpkg-binary-cache jobs: - verify: - name: Local verification graph + verification: + name: ${{ matrix.job }} runs-on: windows-2022 - timeout-minutes: 60 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - job: source + arch: none + - job: x86 + arch: x86 + - job: x64 + arch: x64 + - job: arm64-cross + arch: arm64 + steps: - - uses: actions/checkout@v6 + - name: Check out the repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up uv - uses: astral-sh/setup-uv@v8 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + version: "0.12.1" enable-cache: true cache-dependency-glob: build/uv.lock + cache-suffix: ${{ matrix.job }} + + - name: Identify the hosted runner image + id: runner-image + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:ImageOS) -or + [string]::IsNullOrWhiteSpace($env:ImageVersion)) { + throw 'GitHub runner image identity is unavailable.' + } + "identity=$($env:ImageOS)-$($env:ImageVersion)" | Add-Content -Path $env:GITHUB_OUTPUT - name: Cache vcpkg binaries - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} - key: vcpkg-${{ runner.os }}-x64-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - name: Prepare pinned Python environment + - name: Prepare the pinned Python environment shell: pwsh run: | New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null @@ -48,6 +75,11 @@ jobs: shell: pwsh run: | choco install cppcheck --version=2.19.0 --yes --no-progress + $cppcheck = 'C:\Program Files\Cppcheck\cppcheck.exe' + if (-not (Test-Path -LiteralPath $cppcheck -PathType Leaf)) { + throw "Cppcheck was not found after installation: $cppcheck" + } + (Split-Path -Parent $cppcheck) | Add-Content -Path $env:GITHUB_PATH Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force dotnet tool install --global Microsoft.CodeAnalysis.BinSkim --version 4.4.9.11 (Join-Path $env:USERPROFILE '.dotnet\tools') | Add-Content -Path $env:GITHUB_PATH @@ -77,10 +109,42 @@ jobs: shell: pwsh run: ./build.ps1 doctor - # CI repeats the local graph with shorter bounded dynamic-test parameters. - # Gate composition and scheduling belong exclusively to build.ps1. - - name: Run the local verification graph + - name: Verify repository sources + if: matrix.job == 'source' shell: pwsh - run: >- - ./build.ps1 verify -Arch x64 -TestShards 4 -FuzzSeconds 5 - -LeakWarmup 1 -LeakIterations 1 -LeakWindows 3 + run: | + $evidence = Join-Path $env:RUNNER_TEMP 'evidence-source' + ./build.ps1 verify-source -ExportDir $evidence + + - name: Verify one architecture + if: matrix.job != 'source' + shell: pwsh + run: | + $evidence = Join-Path $env:RUNNER_TEMP 'evidence-${{ matrix.job }}' + $fuzzSeconds = if ('${{ github.event_name }}' -eq 'pull_request') { 5 } else { 60 } + $leakWarmup = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 8 } + $leakIterations = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 100 } + ./build.ps1 verify-arch -Arch '${{ matrix.arch }}' -ExportDir $evidence ` + -TestShards 4 -FuzzSeconds $fuzzSeconds -LeakWarmup $leakWarmup ` + -LeakIterations $leakIterations -LeakWindows 3 + + - name: Upload verification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: evidence-${{ matrix.job }} + path: | + ${{ runner.temp }}/evidence-${{ matrix.job }}/manifest.json + ${{ runner.temp }}/evidence-${{ matrix.job }}/reports + ${{ runner.temp }}/evidence-${{ matrix.job }}/logs + if-no-files-found: error + retention-days: ${{ github.event_name == 'pull_request' && 7 || 30 }} + + - name: Upload master packages and symbols + if: success() && github.event_name == 'push' && matrix.job != 'source' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: packages-${{ matrix.job }} + path: ${{ runner.temp }}/evidence-${{ matrix.job }}/packages + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 3c5aac7..7312e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /out/ /build/.venv/ /build/.uv-cache/ +/build/.coverage* /.idea/ diff --git a/AGENTS.md b/AGENTS.md index 44ca438..d8aae9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ Use the root entry point from an ordinary PowerShell or `cmd.exe` console: .\build.ps1 doctor .\build.ps1 build -Arch all -Config Release .\build.ps1 test -Arch x86,x64 -Config Debug +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir .\build.ps1 verify -Arch x64 ``` diff --git a/build/core/clean.py b/build/core/clean.py index 8e2da1d..434566e 100644 --- a/build/core/clean.py +++ b/build/core/clean.py @@ -91,7 +91,7 @@ def _require_inactive(paths: BuildPaths) -> tuple[Path, ...]: def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]: - """Remove all output or inactive run work, never anything outside exact ``out``.""" + """Remove all output or inactive work, including legacy CAS entry names.""" if mode not in _MODES: raise ValueError(f"unsupported clean mode: {mode}") diff --git a/build/core/execute.py b/build/core/execute.py index 3018061..053ad97 100644 --- a/build/core/execute.py +++ b/build/core/execute.py @@ -43,6 +43,8 @@ def __init__( self._publish = publish self._acquire_lock = acquire_lock self._visited: set[str] = set() + self._failed: dict[str, Exception] = {} + self._blocked: set[str] = set() self._locks = {node.name: asyncio.Lock() for node in graph.nodes} self._pools = { name: asyncio.Semaphore(capacity) for name, capacity in graph.pools.items() @@ -58,28 +60,48 @@ async def run(self, targets: tuple[str, ...] | None = None) -> None: if not requested: raise GraphError("explicit target set must not be empty") await self._visit_many(tuple(self._graph.node(name) for name in requested)) + if self._failed: + failures = tuple(sorted(self._failed.items())) + names = ", ".join(name for name, _error in failures) + group = ExceptionGroup( + f"{len(failures)} node(s) failed: {names}", + tuple(error for _name, error in failures), + ) + group.failed_nodes = tuple(name for name, _error in failures) # type: ignore[attr-defined] + raise group async def _visit(self, current: Node) -> None: async with self._locks[current.name]: - if current.name in self._visited: - return - if self._is_complete(current): - self._visited.add(current.name) + if ( + current.name in self._visited + or current.name in self._failed + or current.name in self._blocked + ): return - - async with self._acquire_lock(current): + try: if self._is_complete(current): self._visited.add(current.name) return - await self._visit_many(self._graph.dependencies_of(current.name)) - async with self._capacity(current): - await self._runner(current) - self._publish(current) - if not self._is_complete(current): - raise ExecutionError( - f"node {current.name!r} returned without complete output" - ) - self._visited.add(current.name) + + async with self._acquire_lock(current): + if self._is_complete(current): + self._visited.add(current.name) + return + dependencies = self._graph.dependencies_of(current.name) + await self._visit_many(dependencies) + if any(dependency.name not in self._visited for dependency in dependencies): + self._blocked.add(current.name) + return + async with self._capacity(current): + await self._runner(current) + self._publish(current) + if not self._is_complete(current): + raise ExecutionError( + f"node {current.name!r} returned without complete output" + ) + self._visited.add(current.name) + except Exception as error: + self._failed[current.name] = error @asynccontextmanager async def _capacity(self, current: Node): diff --git a/build/core/graph.py b/build/core/graph.py index b0fa660..fbc8015 100644 --- a/build/core/graph.py +++ b/build/core/graph.py @@ -16,6 +16,9 @@ class GraphError(ValueError): NODE_SLUG_MAX_LENGTH = 128 _SLUG = re.compile(rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}") _MD5_UID = re.compile(r"[0-9a-f]{32}") +_RESULT_ID = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*") +_RESULT_KIND = re.compile(r"[a-z][a-z0-9._-]*") +_MEDIA_TYPE = re.compile(r"[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*") def _text(value: object, description: str) -> str: @@ -61,6 +64,36 @@ def __post_init__(self) -> None: object.__setattr__(self, "env", tuple(sorted(env, key=lambda pair: pair[0].casefold()))) +@dataclass(frozen=True, slots=True) +class Result: + """One stable logical result at a portable path below the node output.""" + + id: str + kind: str + media_type: str + relative_path: str + + def __post_init__(self) -> None: + if not isinstance(self.id, str) or _RESULT_ID.fullmatch(self.id) is None: + raise GraphError("result id must be a lowercase slash-separated identifier") + if not isinstance(self.kind, str) or _RESULT_KIND.fullmatch(self.kind) is None: + raise GraphError("result kind must be a lowercase identifier") + if not isinstance(self.media_type, str) or _MEDIA_TYPE.fullmatch(self.media_type) is None: + raise GraphError("result media type must be a canonical lowercase MIME type") + if not isinstance(self.relative_path, str): + raise GraphError("result path must be a portable relative path") + parts = self.relative_path.split("/") + if ( + not self.relative_path + or "\0" in self.relative_path + or "\\" in self.relative_path + or ":" in self.relative_path + or self.relative_path.startswith("/") + or any(part in {"", ".", ".."} for part in parts) + ): + raise GraphError("result path must be a portable relative path") + + @dataclass(frozen=True, slots=True) class Node: """One cacheable command whose inputs name its direct dependency nodes.""" @@ -70,6 +103,7 @@ class Node: pool: str command: Command inputs: tuple[str, ...] = () + results: tuple[Result, ...] = () def __post_init__(self) -> None: if not isinstance(self.name, str) or _SLUG.fullmatch(self.name) is None: @@ -87,17 +121,25 @@ def __post_init__(self) -> None: _text(dependency, "dependency name") if len(inputs) != len(set(inputs)): raise GraphError(f"node {self.name!r} contains duplicate dependencies") + results = tuple(self.results) + if any(not isinstance(result, Result) for result in results): + raise GraphError("node results must be Result instances") + result_ids = tuple(result.id for result in results) + if len(result_ids) != len(set(result_ids)): + raise GraphError(f"node {self.name!r} contains duplicate result ids") object.__setattr__(self, "inputs", inputs) + object.__setattr__(self, "results", results) @dataclass(frozen=True, slots=True) class Graph: - """Validated DAG keyed only by readable node names.""" + """Validated DAG with readable node names and stable logical result ids.""" nodes: tuple[Node, ...] targets: tuple[str, ...] pools: Mapping[str, int] _by_name: Mapping[str, Node] = field(init=False, repr=False, compare=False) + _by_result: Mapping[str, tuple[Node, Result]] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: nodes = tuple(self.nodes) @@ -109,12 +151,17 @@ def __post_init__(self) -> None: raise GraphError("graph must contain at least one target") by_name: dict[str, Node] = {} + by_result: dict[str, tuple[Node, Result]] = {} for current in nodes: if not isinstance(current, Node): raise GraphError("graph nodes must be Node instances") if current.name in by_name: raise GraphError(f"duplicate node name: {current.name}") by_name[current.name] = current + for result in current.results: + if result.id in by_result: + raise GraphError(f"duplicate result id: {result.id}") + by_result[result.id] = (current, result) for name, capacity in pools.items(): _text(name, "pool name") @@ -149,6 +196,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "targets", targets) object.__setattr__(self, "pools", MappingProxyType(pools)) object.__setattr__(self, "_by_name", MappingProxyType(by_name)) + object.__setattr__(self, "_by_result", MappingProxyType(by_result)) def node(self, name: str) -> Node: try: @@ -159,6 +207,12 @@ def node(self, name: str) -> Node: def dependencies_of(self, name: str) -> tuple[Node, ...]: return tuple(self.node(dependency) for dependency in self.node(name).inputs) + def result(self, result_id: str) -> tuple[Node, Result]: + try: + return self._by_result[result_id] + except KeyError as error: + raise GraphError(f"unknown result {result_id!r}") from error + def merge_graphs(*graphs: Graph) -> Graph: """Union independent graphs while deduplicating byte-identical shared nodes.""" diff --git a/build/core/leak.py b/build/core/leak.py index 151b714..d7c0ad9 100644 --- a/build/core/leak.py +++ b/build/core/leak.py @@ -199,7 +199,7 @@ def _diff(args: Sequence[str]) -> None: _json("diff.json", {"label": label, "totalIncrease": total, "positiveStacks": stacks, "report": "report.txt"}) -def _judge(args: Sequence[str]) -> None: +def _summary(args: Sequence[str]) -> dict[str, object]: if len(args) < 8 or len(args) % 2: raise LeakError("judge expects settings followed by label/report pairs") mode, scenario, warmup_value, iterations_value, windows_value, tolerance_value, *pairs = args @@ -215,11 +215,35 @@ def _judge(args: Sequence[str]) -> None: sustained = last["totalIncrease"] > tolerance and previous["totalIncrease"] > tolerance and overall["totalIncrease"] > 2 * tolerance summary = {"mode": mode, "scenario": scenario, "warmupRounds": warmup, "iterationsPerWindow": iterations, "windows": windows, "toleranceBytes": tolerance, "totalGrowthByWindow": [record["totalIncrease"] for record in records[:-1]], "overallGrowthBytes": overall["totalIncrease"], "repeatedGrowingStacks": repeated, "passed": not sustained and not repeated} _json("summary.json", summary) + return summary + + +def _summarize(args: Sequence[str]) -> None: + _summary(args) + + +def _judge(args: Sequence[str]) -> None: + summary = _summary(args) if not summary["passed"]: raise LeakError("UMDH found sustained heap growth") -_ACTIONS = {"setup": _setup, "preflight": _preflight, "capture": _capture, "diff": _diff, "judge": _judge} +def _gate(args: Sequence[str]) -> None: + (path,) = _exact("gate", args, 1) + try: + passed = json.loads(Path(path).read_text(encoding="utf-8"))["passed"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise LeakError("leak summary is invalid") from error + if not isinstance(passed, bool): + raise LeakError("leak summary is invalid") + if not passed: + raise LeakError("UMDH found sustained heap growth") + + +_ACTIONS = { + "setup": _setup, "preflight": _preflight, "capture": _capture, + "diff": _diff, "summarize": _summarize, "gate": _gate, "judge": _judge, +} def main(argv: Sequence[str] | None = None) -> int: diff --git a/build/core/node.py b/build/core/node.py index 0881b84..8798e47 100644 --- a/build/core/node.py +++ b/build/core/node.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from core.graph import Node +from core.graph import Node, Result from core.recipe import Recipe from core.render import TemplateRenderer from core.sign import content_uid @@ -26,6 +26,7 @@ def make( *, files: dict[str, bytes], dependencies: tuple[Node, ...] = (), + results: tuple[Result, ...] = (), config: dict[str, str], identity: dict[str, str] | None = None, environment: tuple[tuple[str, str], ...] | None = None, @@ -36,6 +37,15 @@ def make( "pool": pool, "inputs": [node.name for node in dependencies], } | variables + descriptor["results"] = [ + { + "id": result.id, + "kind": result.kind, + "media_type": result.media_type, + "path": result.relative_path, + } + for result in results + ] rendered = self.renderer.render(template, descriptor) node_environment = self.environment if environment is None else environment node_cwd = cwd or self.cwd diff --git a/build/core/package.py b/build/core/package.py index d722c81..ea71847 100644 --- a/build/core/package.py +++ b/build/core/package.py @@ -165,8 +165,11 @@ def _aggregate(args: argparse.Namespace) -> None: names = [archive.name for archive in args.archives] if len(names) != len(set(names)): raise PackageError("duplicate archive name in package manifest") + output = _output() + for archive in sorted(args.archives, key=lambda path: path.name): + shutil.copyfile(archive, output / archive.name) _write_json( - _output() / "packages.json", + output / "packages.json", [ {"name": archive.name, "sha256": _sha256(archive)} for archive in sorted(args.archives, key=lambda path: path.name) diff --git a/build/core/paths.py b/build/core/paths.py index 93cb2da..34d6bc4 100644 --- a/build/core/paths.py +++ b/build/core/paths.py @@ -6,13 +6,7 @@ from dataclasses import dataclass from pathlib import Path -from .graph import NODE_SLUG_MAX_LENGTH - - _UID_PATTERN = re.compile(r"[0-9a-f]{32}\Z") -_NODE_PATTERN = re.compile( - rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}\Z" -) _RUN_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") @@ -69,14 +63,9 @@ def prepare(self) -> None: ): self.require_confined(path, root).mkdir(exist_ok=True) - def cas(self, uid: str, node: str) -> CasPaths: + def cas(self, uid: str) -> CasPaths: self._require_uid(uid) - if _NODE_PATTERN.fullmatch(node) is None: - raise PathSafetyError( - "invalid lowercase node slug; expected at most " - f"{NODE_SLUG_MAX_LENGTH} ASCII characters: {node!r}" - ) - entry = self.require_confined(self.cas_root / f"{uid}-{node}", self.cas_root) + entry = self.require_confined(self.cas_root / uid, self.cas_root) paths = CasPaths( entry=entry, output=entry / "out", diff --git a/build/core/recipe.py b/build/core/recipe.py index 8b92ebd..0c9cd23 100644 --- a/build/core/recipe.py +++ b/build/core/recipe.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import json -from core.graph import Command, Node +from core.graph import Command, GraphError, Node, Result class RecipeError(ValueError): @@ -20,6 +20,7 @@ class Recipe: inputs: tuple[str, ...] argv: tuple[str, ...] data: bytes + results: tuple[Result, ...] = () @classmethod def parse(cls, rendered: str | bytes) -> Recipe: @@ -28,12 +29,24 @@ def parse(cls, rendered: str | bytes) -> Recipe: try: document = json.loads(rendered) script = document["script"] + declared_results = document["results"] + if not isinstance(declared_results, list): + raise TypeError("results must be a list") return cls( name=document["name"], pool=document["pool"], inputs=tuple(document["inputs"]), argv=tuple(script["exec"]), data=script["data"].encode("utf-8"), + results=tuple( + Result( + result["id"], + result["kind"], + result["media_type"], + result["path"], + ) + for result in declared_results + ), ) except ( json.JSONDecodeError, @@ -41,6 +54,7 @@ def parse(cls, rendered: str | bytes) -> Recipe: KeyError, TypeError, AttributeError, + GraphError, ) as error: raise RecipeError("rendered recipe is missing required JSON fields") from error @@ -58,4 +72,5 @@ def to_node( pool=self.pool, command=Command(self.argv, tuple(environment), cwd, self.data), inputs=self.inputs, + results=self.results, ) diff --git a/build/core/result_export.py b/build/core/result_export.py new file mode 100644 index 0000000..767c538 --- /dev/null +++ b/build/core/result_export.py @@ -0,0 +1,289 @@ +"""Atomically publish declared graph results without exposing CAS paths.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import tempfile +from typing import Any, Iterable + +from core.graph import Graph, Node, Result +from core.store import CasStore + + +_COMMAND = re.compile(r"[a-z0-9][a-z0-9._-]*") +_STATUSES = {"success", "failed"} +_PACKAGE_PREFIX = "packages/" +_RESERVED = ("manifest.json", "logs", "packages") +_PACKAGE_RESERVED = ("manifest.json",) + + +class ResultExportError(RuntimeError): + """Declared results could not be published safely and completely.""" + + +def _lstat(path: Path) -> os.stat_result | None: + try: + return os.lstat(path) + except FileNotFoundError: + return None + + +def _is_reparse(path: Path) -> bool: + information = os.lstat(path) + attributes = getattr(information, "st_file_attributes", 0) + reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute) + + +def _checked(path: Path) -> os.stat_result: + information = _lstat(path) + if information is None: + raise ResultExportError(f"declared result is missing: {path.name}") + if _is_reparse(path): + raise ResultExportError(f"reparse points are forbidden in exported results: {path.name}") + return information + + +def _source(output: Path, result: Result) -> tuple[Path, os.stat_result]: + parts = result.relative_path.split("/") + current = output / parts[0] + information = _checked(current) + for part in parts[1:]: + if not stat.S_ISDIR(information.st_mode): + raise ResultExportError(f"declared result path is not a directory: {current.name}") + current /= part + information = _checked(current) + return current, information + + +def _digest_file(path: Path) -> tuple[int, str]: + size = path.stat().st_size + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return size, digest + + +def _copy_file(source: Path, destination: Path) -> tuple[int, str]: + information = _checked(source) + if not stat.S_ISREG(information.st_mode): + raise ResultExportError(f"result is not a regular file: {source.name}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination, follow_symlinks=False) + return _digest_file(destination) + + +def _tree_record( + digest: Any, marker: bytes, relative: Path, size: int, content_digest: str = "" +) -> None: + encoded = relative.as_posix().encode("utf-8") + digest.update(marker) + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + digest.update(size.to_bytes(8, "big")) + digest.update(bytes.fromhex(content_digest)) + + +def _copy_directory(source: Path, destination: Path) -> tuple[int, str]: + destination.mkdir(parents=True) + total = 0 + digest = hashlib.sha256() + pending = [(source, Path())] + while pending: + current, relative_root = pending.pop() + directories: list[tuple[Path, Path]] = [] + with os.scandir(current) as entries: + for entry in sorted(entries, key=lambda item: item.name): + source_entry = Path(entry.path) + relative = relative_root / entry.name + information = _checked(source_entry) + if stat.S_ISDIR(information.st_mode): + (destination / relative).mkdir() + _tree_record(digest, b"D", relative, 0) + directories.append((source_entry, relative)) + elif stat.S_ISREG(information.st_mode): + size, file_digest = _copy_file(source_entry, destination / relative) + total += size + _tree_record(digest, b"F", relative, size, file_digest) + else: + raise ResultExportError(f"unsupported result entry: {entry.name}") + pending.extend(reversed(directories)) + return total, digest.hexdigest() + + +def _copy_result(source: Path, information: os.stat_result, destination: Path) -> tuple[str, int, str]: + if stat.S_ISREG(information.st_mode): + size, digest = _copy_file(source, destination) + return "file", size, digest + if stat.S_ISDIR(information.st_mode): + size, digest = _copy_directory(source, destination) + return "directory", size, digest + raise ResultExportError(f"result must be a file or directory: {source.name}") + + +def _overlap(first: str, second: str) -> bool: + return first == second or first.startswith(second + "/") or second.startswith(first + "/") + + +def _validate_result_paths(graph: Graph) -> None: + occupied = list(_RESERVED) + package_occupied = list(_PACKAGE_RESERVED) + for current in graph.nodes: + for result in current.results: + if result.id.startswith(_PACKAGE_PREFIX): + relative = result.id.removeprefix(_PACKAGE_PREFIX) + paths = package_occupied + else: + relative = result.id + paths = occupied + if any(_overlap(relative, path) for path in paths): + raise ResultExportError(f"colliding export path: {result.id}") + paths.append(relative) + + +def _validate_manifest_inputs( + graph: Graph, command: str, status: str, failures: Iterable[str] +) -> tuple[str, ...]: + if not isinstance(command, str) or _COMMAND.fullmatch(command) is None: + raise ResultExportError("command must be a lowercase logical name") + if status not in _STATUSES: + raise ResultExportError(f"invalid export status: {status!r}") + failure_names = tuple(failures) + known = {current.name for current in graph.nodes} + if len(failure_names) != len(set(failure_names)) or any( + name not in known for name in failure_names + ): + raise ResultExportError("failures must contain unique graph node names") + if status != "failed" and failure_names: + raise ResultExportError("successful export cannot contain failures") + return failure_names + + +def _validate_destination(destination: Path) -> None: + if _lstat(destination) is not None: + raise ResultExportError(f"export destination already exists: {destination}") + information = _lstat(destination.parent) + if information is None or not stat.S_ISDIR(information.st_mode): + raise ResultExportError("export destination parent must be an existing directory") + current = destination.parent + while True: + if _is_reparse(current): + raise ResultExportError(f"reparse point in export destination: {current}") + if current.parent == current: + return + current = current.parent + + +def _write_manifest(staging: Path, document: dict[str, object]) -> None: + with (staging / "manifest.json").open("x", encoding="utf-8", newline="\n") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + + +def _result_entry( + node: Node, result: Result, staging: Path, store: CasStore, relative: str +) -> dict[str, object]: + source, information = _source(store.paths_for(node).output, result) + object_type, size, digest = _copy_result(source, information, staging / relative) + return { + "id": result.id, + "kind": result.kind, + "media_type": result.media_type, + "object_type": object_type, + "path": relative, + "producer_uid": node.uid, + "sha256": digest, + "size": size, + } + + +def export_results( + graph: Graph, + store: CasStore, + destination: Path | str, + command: str, + status: str, + *, + failures: Iterable[str] = (), +) -> Path: + """Publish complete declared results and a relative-path-only manifest.""" + + failure_names = _validate_manifest_inputs(graph, command, status, failures) + _validate_result_paths(graph) + published = Path(os.path.abspath(os.fspath(destination))) + _validate_destination(published) + try: + with tempfile.TemporaryDirectory( + prefix=f".{published.name}.tmp-", dir=published.parent + ) as temporary: + staging = Path(temporary) + complete_results = [ + (current, result) + for current in graph.nodes + if store.is_complete(current) + for result in current.results + ] + results = [ + _result_entry(current, result, staging, store, result.id) + for current, result in complete_results + if not result.id.startswith(_PACKAGE_PREFIX) + ] + package_results: list[dict[str, object]] = [] + if status == "success": + package_root = staging / "packages" + package_results = [ + _result_entry( + current, + result, + package_root, + store, + result.id.removeprefix(_PACKAGE_PREFIX), + ) + for current, result in complete_results + if result.id.startswith(_PACKAGE_PREFIX) + ] + if package_results: + _write_manifest( + package_root, + { + "schema": 1, + "command": command, + "status": "success", + "failures": [], + "results": package_results, + "logs": [], + }, + ) + logs: list[dict[str, object]] = [] + if status == "failed": + for current in graph.nodes: + source = store.paths_for(current).log + if _lstat(source) is None: + continue + relative = f"logs/{current.name}.log" + size, digest = _copy_file(source, staging / relative) + logs.append( + {"node": current.name, "path": relative, "sha256": digest, "size": size} + ) + _write_manifest( + staging, + { + "schema": 1, + "command": command, + "status": status, + "failures": list(failure_names), + "results": results, + "logs": logs, + }, + ) + if _lstat(published) is not None: + raise ResultExportError(f"export destination already exists: {published}") + staging.rename(published) + except OSError as error: + raise ResultExportError(f"could not publish result export: {error}") from error + return published diff --git a/build/core/store.py b/build/core/store.py index 750461e..5dc60eb 100644 --- a/build/core/store.py +++ b/build/core/store.py @@ -47,7 +47,7 @@ def __init__(self, paths: BuildPaths, run_identifier: str) -> None: self._run_work = paths.run_work(run_identifier) def paths_for(self, current: Node) -> CasPaths: - return self._paths.cas(current.uid, current.name) + return self._paths.cas(current.uid) def is_complete(self, current: Node) -> bool: return _complete(self.paths_for(current)) diff --git a/build/driver.py b/build/driver.py index 0bd9b00..1bc4ba7 100644 --- a/build/driver.py +++ b/build/driver.py @@ -2,14 +2,13 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from pathlib import Path import psutil -from core.graph import Graph, Node, merge_graphs +from core.graph import Graph, merge_graphs from core.host import require_runnable, runnable_architectures, verify_route from core.node import NodeFactory -from core.paths import BuildPaths from core.quality_tools import ( ResolvedTool, resolve_binskim, @@ -22,9 +21,10 @@ from core.source_tools import SourceTools, discover_source_tools from core.toolchain import MsvcToolchain from core.render import TemplateRenderer +from core.result_export import export_results from graphs.analysis import (analysis_discovery_slice, analysis_slice, load_dependency_manifests) -from graphs.audit import BinaryArtifact, audit_graph +from graphs.audit import audit_graph from graphs.common import BUILD_ROOT, require_positive_integers, restore_node, tool_environment from graphs.coverage import coverage_dependency_discovery_slice, coverage_graph from graphs.fuzz import ( @@ -41,8 +41,8 @@ ) from graphs.leak import leak_graph from graphs.native import native_dependency_discovery_slice, native_graph -from graphs.package import PackageArtifact, PackageSmokeArtifact, package_graph, package_outputs -from graphs.source import source_checks as source_checks_graph +from graphs.package import package_graph, package_outputs +from graphs.source import architecture_source_checks, common_source_checks, source_checks as source_checks_graph _MODULES = ("renpy", "rpgmaker", "zanzarah") @@ -75,11 +75,41 @@ def __init__(self, repository: Path, run_id: str, toolchain: MsvcToolchain, require_positive_integers((self.jobs,), "jobs must be a positive integer") self.toolchain = toolchain self.runtime = BuildRuntime(self.repository, run_id) + self._last_graph: Graph | None = None async def _run(self, graph: Graph) -> tuple[Path, ...]: + self._last_graph = graph await self.runtime.executor(graph).run() return tuple(self.runtime.store.paths_for(graph.node(name)).output for name in graph.targets) + async def _public( + self, command: str, export_dir: Path | None, + action: Callable[[], Awaitable[tuple[Path, ...]]], + ) -> tuple[Path, ...]: + self._last_graph = None + try: + outputs = await action() + except Exception as error: + if export_dir is not None and self._last_graph is not None: + failures = tuple(getattr(error, "failed_nodes", ())) + try: + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "failed", failures=failures, + ) + except Exception as export_error: + raise ExceptionGroup( + f"{command} and result export failed", (error, export_error) + ) from None + raise + if export_dir is not None: + assert self._last_graph is not None + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "success", failures=(), + ) + return outputs + async def _staged(self, discovery: Graph, compose: Callable[..., Graph], **options: object) -> Graph: await self._run(discovery) @@ -103,46 +133,18 @@ async def _native(self, architectures: tuple[str, ...], configurations: tuple[st include_leak_probe=include_leak_probe, corpus=corpus, run_nonce=run_nonce, ) - def _release(self, graph: Graph, architecture: str, module: str) -> tuple[Node, Path]: - producer = graph.node(f"build-{module}-{architecture}-release") - return producer, BuildPaths(self.repository).cas(producer.uid, producer.name).output - - def _binaries(self, graph: Graph, architectures: tuple[str, ...], *, - include_leak_probe: bool = False) -> tuple[BinaryArtifact, ...]: - modules = (("leak-probe",) if include_leak_probe else ()) + _MODULES - return tuple( - BinaryArtifact(architecture, module, producer, output / - ("leak-probe.exe" if module == "leak-probe" else f"{module}.so")) - for architecture in architectures for module in modules - for producer, output in (self._release(graph, architecture, module),) - ) - - def _packages(self, graph: Graph, architectures: tuple[str, ...]) -> tuple[PackageArtifact, ...]: - return tuple( - PackageArtifact(architecture, module, producer, output / f"{module}.so", - output / f"{module}.pdb") - for architecture in architectures for module in _MODULES - for producer, output in (self._release(graph, architecture, module),) - ) - - def _smokes(self, graph: Graph, architectures: tuple[str, ...]) -> tuple[PackageSmokeArtifact, ...]: - return tuple( - PackageSmokeArtifact(architecture, producer, output / "tests.exe") - for architecture in architectures - for producer, output in (self._release(graph, architecture, "tests"),) - ) - async def _audited_release(self, architectures: tuple[str, ...], dumpbin: ResolvedTool, - binskim: ResolvedTool, binskim_jobs: int, *, + binskim: ResolvedTool, *, include_leak_probe: bool = False) -> Graph: upstream = await self._native( architectures, ("Release",), (), include_leak_probe=include_leak_probe ) return audit_graph( - self.repository, upstream, self._binaries(upstream, architectures), + self.repository, upstream, architectures=architectures, + include_leak_probe=include_leak_probe, dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), binskim=binskim.path, binskim_identity=dict(binskim.identity), - jobs=self.jobs, binskim_jobs=binskim_jobs, + jobs=self.jobs, ) async def restore(self, architectures: tuple[str, ...] = ("x64",), @@ -266,47 +268,50 @@ async def fuzz(self, *, run_nonce: str, seconds: int = 60, fuzz_jobs: int = 2, return await self._run(graph) async def audit(self, architectures: tuple[str, ...] = ("x64",), *, - dumpbin: ResolvedTool, binskim: ResolvedTool, - binskim_jobs: int = 1) -> tuple[Path, ...]: + dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]: return await self._run( - await self._audited_release(architectures, dumpbin, binskim, binskim_jobs) + await self._audited_release(architectures, dumpbin, binskim) ) - async def package(self, architectures: tuple[str, ...] = ("x64",), *, - dumpbin: ResolvedTool, binskim: ResolvedTool, - binskim_jobs: int = 1) -> tuple[Path, ...]: - audited = await self._audited_release(architectures, dumpbin, binskim, binskim_jobs) + async def _package(self, architectures: tuple[str, ...], *, + dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]: + audited = await self._audited_release(architectures, dumpbin, binskim) graph = package_graph( - self.repository, audited, self._packages(audited, architectures), - smoke_tests=self._smokes(audited, runnable_architectures(architectures)), jobs=self.jobs, + self.repository, audited, architectures=architectures, + smoke_architectures=runnable_architectures(architectures), jobs=self.jobs, ) await self._run(graph) return package_outputs(self.repository, graph) + async def package(self, architectures: tuple[str, ...] = ("x64",), *, + dumpbin: ResolvedTool, binskim: ResolvedTool, + export_dir: Path | None = None) -> tuple[Path, ...]: + return await self._public( + "package", export_dir, + lambda: self._package(architectures, dumpbin=dumpbin, binskim=binskim), + ) + async def test_leaks(self, *, run_nonce: str, dumpbin: ResolvedTool, - binskim: ResolvedTool, umdh: ResolvedTool, binskim_jobs: int = 1, + binskim: ResolvedTool, umdh: ResolvedTool, warmup: int = 8, iterations: int = 100, windows: int = 3, - tolerance_bytes: int = 0, session_jobs: int = 2, - diff_jobs: int = 4) -> tuple[Path, ...]: + tolerance_bytes: int = 0) -> tuple[Path, ...]: require_runnable(("x64",)) audited = await self._audited_release( - ("x64",), dumpbin, binskim, binskim_jobs, include_leak_probe=True + ("x64",), dumpbin, binskim, include_leak_probe=True ) graph = leak_graph( self.repository, audited, - self._binaries(audited, ("x64",), include_leak_probe=True), umdh=umdh.path, umdh_identity=dict(umdh.identity), run_nonce=run_nonce, warmup=warmup, iterations=iterations, windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs, - session_jobs=session_jobs, diff_jobs=diff_jobs, ) return await self._run(graph) - async def verify( + async def _verify( self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, warmup: int = 8, iterations: int = 100, windows: int = 3, - tolerance_bytes: int = 0, + tolerance_bytes: int = 0, include_common: bool, ) -> tuple[Path, ...]: """Run every host-capable gate in one shared-pool graph after one discovery union.""" @@ -362,14 +367,14 @@ async def verify( corpus=corpus, run_nonce=run_nonce, ) audit = audit_graph( - self.repository, native, self._binaries(native, architectures), + self.repository, native, architectures=architectures, dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), binskim=binskim.path, binskim_identity=dict(binskim.identity), - jobs=self.jobs, binskim_jobs=1, + jobs=self.jobs, ) package = package_graph( - self.repository, audit, self._packages(audit, architectures), - smoke_tests=self._smokes(audit, route.runnable), jobs=self.jobs, + self.repository, audit, architectures=architectures, + smoke_architectures=route.runnable, jobs=self.jobs, ) graphs = [ native, @@ -377,12 +382,16 @@ async def verify( self.repository, self.toolchain, discovery=discovery, manifests=manifests, jobs=self.jobs, architectures=architectures, ), - source_checks_graph( - self.repository, source_tools, jobs=self.jobs, architectures=architectures, + architecture_source_checks( + self.repository, source_tools, architectures, jobs=self.jobs, ), - python_coverage_graph(self.repository), package, ] + if include_common: + graphs.extend(( + common_source_checks(self.repository, source_tools, jobs=self.jobs), + python_coverage_graph(self.repository), + )) if route.coverage: graphs.append(coverage_graph( self.repository, self.toolchain, discovery=discovery, manifests=manifests, @@ -406,10 +415,54 @@ async def verify( ), leak_graph( self.repository, audit, - self._binaries(audit, ("x64",), include_leak_probe=True), umdh=umdh.path, umdh_identity=dict(umdh.identity), run_nonce=run_nonce, warmup=warmup, iterations=iterations, windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs, ), )) return await self._run(merge_graphs(*graphs)) + + async def verify_source(self, *, export_dir: Path | None = None) -> tuple[Path, ...]: + async def operation() -> tuple[Path, ...]: + tools = discover_source_tools(self.toolchain) + graph = merge_graphs( + common_source_checks(self.repository, tools, jobs=self.jobs), + python_coverage_graph(self.repository), + ) + return await self._run(graph) + + return await self._public("verify-source", export_dir, operation) + + async def verify_arch( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, export_dir: Path | None = None, + ) -> tuple[Path, ...]: + if len(architectures) != 1: + raise ValueError("verify-arch requires exactly one architecture") + return await self._public( + "verify-arch", export_dir, + lambda: self._verify( + architectures, corpus=corpus, run_nonce=run_nonce, + fuzz_seconds=fuzz_seconds, test_shards=test_shards, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, include_common=False, + ), + ) + + async def verify( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, export_dir: Path | None = None, + ) -> tuple[Path, ...]: + return await self._public( + "verify", export_dir, + lambda: self._verify( + architectures, corpus=corpus, run_nonce=run_nonce, + fuzz_seconds=fuzz_seconds, test_shards=test_shards, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, include_common=True, + ), + ) diff --git a/build/graphs/analysis.py b/build/graphs/analysis.py index b329695..908f578 100644 --- a/build/graphs/analysis.py +++ b/build/graphs/analysis.py @@ -3,28 +3,32 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass import json from pathlib import Path import xml.etree.ElementTree as ET -from core.graph import Graph, Node +from core.graph import Graph, Node, Result from core.node import NodeFactory from core.paths import BuildPaths from core.quality_tools import ResolvedTool, resolve_llvm -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain -from graphs.common import python_action, restore_node, tool_environment +from graphs.common import ( + COMMON_PROJECT_INPUTS, PLATFORMS, project_path, python_action, recipe_factory, + restore_node, tool_environment, +) _BUILD_ROOT = Path(__file__).resolve().parents[1] _MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" -_PROJECT_PREFIX = "$(RepositoryRoot)" -_COMMON_INPUTS = ( - "build/ObserverProjectConfigurations.props", - "build/ObserverConfiguration.props", - "build/ObserverProject.props", -) -_PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"} + + +@dataclass(frozen=True, slots=True) +class MsbuildProject: + name: str + path: Path + inputs: tuple[str, ...] + sources: tuple[Path, ...] def _relative(repository: Path, path: Path) -> str: @@ -33,23 +37,49 @@ def _relative(repository: Path, path: Path) -> str: def _platform(architecture: str) -> str: try: - return _PLATFORMS[architecture] + return PLATFORMS[architecture] except KeyError as error: raise ValueError(f"unsupported architecture: {architecture}") from error +def project_inventory( + repository: Path, names: tuple[str, ...] | None = None, *, + include_link_inputs: bool = True, +) -> tuple[MsbuildProject, ...]: + """Parse each selected project once into immutable signed inputs and sources.""" + + root = repository.resolve(strict=True) + paths = (tuple(sorted((root / "build/projects").glob("*.vcxproj"))) if names is None + else tuple(root / "build/projects" / f"{name}.vcxproj" for name in names)) + projects = [] + for project in paths: + document = ET.parse(project).getroot() + inputs = list(COMMON_PROJECT_INPUTS) + [project.relative_to(root).as_posix()] + if include_link_inputs: + inputs.extend( + project_path(root, item.text.strip(), project) for item in + document.iter(f"{_MSBUILD_NS}ModuleDefinitionFile") if item.text + ) + sources = tuple( + root / project_path(root, item.get("Include", ""), project) for item in + document.iter(f"{_MSBUILD_NS}ClCompile") if item.get("Include") + ) + projects.append(MsbuildProject( + project.stem, project, tuple(dict.fromkeys(inputs)), sources + )) + return tuple(projects) + + def _projects(repository: Path) -> tuple[tuple[str, Path, Path], ...]: - units = [] - for project in sorted((repository / "build/projects").glob("*.vcxproj")): - for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ClCompile"): - include = item.get("Include", "") - if not include: - continue - if not include.startswith(_PROJECT_PREFIX): - raise ValueError(f"unsupported ClCompile path in {project}: {include}") - relative = include.removeprefix(_PROJECT_PREFIX).replace("\\", "/") - units.append((project.stem, project, (repository / relative).resolve(strict=True))) - return tuple(units) + try: + projects = project_inventory(repository, include_link_inputs=False) + except ValueError as error: + message = str(error).replace("unsupported project input", "unsupported ClCompile path", 1) + raise ValueError(message) from error + return tuple( + (project.name, project.path, source) for project in projects + for source in project.sources + ) def _unit(repository: Path, source: Path) -> str: @@ -68,7 +98,9 @@ def _project_files( repository: Path, project_name: str, project: Path, source: Path, extra: tuple[str, ...] = (), ) -> dict[str, bytes]: - inputs = list(_COMMON_INPUTS) + [_relative(repository, project), _relative(repository, source)] + inputs = list(COMMON_PROJECT_INPUTS) + [ + _relative(repository, project), _relative(repository, source) + ] if project_name.startswith("fuzz-"): inputs.append("build/ObserverFuzz.props") inputs.extend(extra) @@ -88,24 +120,18 @@ def _compile_variables( } -def _manifest_index(repository: Path, requested: set[str]) -> dict[str, bytes]: - paths, latest = BuildPaths(repository), {} - if not paths.cas_root.is_dir(): - return {} - for entry in paths.cas_root.glob("*-discover-dependencies-*"): - name = entry.name[33:] - if name not in requested: - continue - try: - cas = paths.cas(entry.name[:32], name) - except ValueError: - continue - manifest = cas.output / "dependencies.json" +def _manifest_index( + repository: Path, requested: Mapping[str, str] +) -> dict[str, bytes]: + paths, manifests = BuildPaths(repository), {} + for name, uid in requested.items(): + cas = paths.cas(uid) + manifest = paths.require_confined( + cas.output / "dependencies.json", paths.cas_root + ) if cas.touch.is_file() and not cas.touch.stat().st_size and manifest.is_file(): - candidate = (cas.touch.stat().st_mtime_ns, entry.name, manifest) - if name not in latest or candidate[:2] > latest[name][:2]: - latest[name] = candidate - return {name: candidate[2].read_bytes() for name, candidate in latest.items()} + manifests[name] = manifest.read_bytes() + return manifests def dependency_inputs( @@ -147,6 +173,32 @@ def dependency_inputs( return files +def project_build( + repository: Path, factory: NodeFactory, discovery: Graph, + manifests: Mapping[str, bytes], restore_output: Path, project: MsbuildProject, + architecture: str, qualifier: str, template: str, variables: dict[str, object], + *, identity: dict[str, str] | None = None, config: dict[str, str], +) -> Node: + """Create one build from the project's exact signed bytes and TU predecessors.""" + + files = {path: (repository / path).read_bytes() for path in project.inputs} + dependencies = [] + for source in project.sources: + name = dependency_node_name( + repository, architecture, project.name, source, qualifier + ) + dependencies.append(discovery.node(name)) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs(repository, restore_output, source, manifest)) + return factory.make( + template, f"build-{project.name}-{architecture}-{qualifier}", "slot", variables, + files=files, dependencies=tuple(dependencies), identity=identity, config=config, + ) + + def _dependency_node( repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, restore: Node, unit: tuple[str, Path, Path], namespace: str, architecture: str, platform: str, @@ -156,7 +208,7 @@ def _dependency_node( name = dependency_node_name( repository, architecture, project_name, source, qualifier ) - restore_output = BuildPaths(repository).cas(restore.uid, restore.name).output + restore_output = BuildPaths(repository).cas(restore.uid).output files = _project_files(repository, project_name, project, source) prior = previous.get(name) if prior is not None: @@ -186,8 +238,7 @@ def dependency_discovery_slice( """Create cacheable per-TU MSVC ``/sourceDependencies`` nodes.""" root = repository.resolve(strict=True) - factory = NodeFactory(TemplateRenderer(_BUILD_ROOT / "templates"), root, - dict(toolchain.identity), tool_environment(toolchain)) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) namespace = "\n".join( _relative(root, path) for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) @@ -195,29 +246,31 @@ def dependency_discovery_slice( projects = tuple( unit for unit in _projects(root) if project_names is None or unit[0] in project_names ) - requested = { - dependency_node_name(root, architecture, unit[0], unit[2], name_qualifier) - for architecture in architectures - for unit in projects - if unit[0] != "leak-probe" or architecture == "x64" - } - previous = _manifest_index(root, requested) - nodes, targets = [], [] - for architecture in architectures: - platform = _platform(architecture) - restore = restore_node(root, toolchain, factory, architecture, flavor=restore_flavor) - nodes.append(restore) - for unit in projects: - project_name = unit[0] - if project_name == "leak-probe" and architecture != "x64": - continue - node = _dependency_node( - root, toolchain, factory, restore, unit, namespace, architecture, - platform, configuration, name_qualifier, previous, + def create(previous: Mapping[str, bytes]) -> Graph: + nodes, targets = [], [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node( + root, toolchain, factory, architecture, flavor=restore_flavor ) - nodes.append(node) - targets.append(node.name) - return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + nodes.append(restore) + for unit in projects: + project_name = unit[0] + if project_name == "leak-probe" and architecture != "x64": + continue + node = _dependency_node( + root, toolchain, factory, restore, unit, namespace, architecture, + platform, configuration, name_qualifier, previous, + ) + nodes.append(node) + targets.append(node.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + base = create({}) + previous = _manifest_index( + root, {name: base.node(name).uid for name in base.targets} + ) + return create(previous) if previous else base def _exact_identity(prefix: str, tool: ResolvedTool) -> dict[str, str]: @@ -233,18 +286,16 @@ def clang_dependency_discovery_slice( """Capture actual clang-cl commands and resolve their exact header graph.""" root = repository.resolve(strict=True) - renderer = TemplateRenderer(_BUILD_ROOT / "templates") environment = tool_environment(toolchain) base_identity = dict(toolchain.identity) clang = resolve_llvm(toolchain, "clang-cl") scanner = resolve_llvm(toolchain, "clang-scan-deps") - clang_factory = NodeFactory( - renderer, + clang_factory = recipe_factory( root, base_identity | _exact_identity("clang_cl", clang), environment, ) - scan_factory = NodeFactory(renderer, root, base_identity, environment) + scan_factory = recipe_factory(root, base_identity, environment) namespace = "\n".join( _relative(root, path) for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) @@ -252,70 +303,71 @@ def clang_dependency_discovery_slice( projects = tuple( unit for unit in _projects(root) if project_names is None or unit[0] in project_names ) - requested = { - dependency_node_name(root, architecture, unit[0], unit[2], name_qualifier) - for architecture in architectures - for unit in projects - if unit[0] != "leak-probe" or architecture == "x64" - } - previous = _manifest_index(root, requested) paths = BuildPaths(root) - nodes: list[Node] = [] - targets: list[str] = [] - for architecture in architectures: - platform = _platform(architecture) - restore = restore_node( - root, toolchain, clang_factory, architecture, flavor=restore_flavor - ) - nodes.append(restore) - restore_output = paths.cas(restore.uid, restore.name).output - for project_name, project, source in projects: - if project_name == "leak-probe" and architecture != "x64": - continue - name = dependency_node_name( - root, architecture, project_name, source, name_qualifier - ) - files = _project_files(root, project_name, project, source) - prior = previous.get(name) - if prior is not None: - files.update(dependency_inputs(root, restore_output, source, prior)) - capture = clang_factory.make( - "clang-command.ps1", - name.replace("discover-dependencies-", "capture-clang-command-", 1), - "slot", - _compile_variables( - toolchain, - project, - source, - restore_output, - configuration, - platform, - ) | {"llvm_dir": str(toolchain.llvm_dir)}, - files=files, - dependencies=(restore,), - config={"architecture": architecture, "source_namespace": namespace}, - ) - command_file = paths.cas(capture.uid, capture.name).output / "compile-command.json" - scan = python_action( - scan_factory, - name, - "core.clang_dependencies", - ( - "scan", - str(source), - str(command_file), - str(scanner.path), - str(clang.path), - ), - (capture,), - pool="slot", - environment=environment, - identity=_exact_identity("clang_scan_deps", scanner), - config={"architecture": architecture, "source_namespace": namespace}, + + def create(previous: Mapping[str, bytes]) -> Graph: + nodes: list[Node] = [] + targets: list[str] = [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node( + root, toolchain, clang_factory, architecture, flavor=restore_flavor ) - nodes.extend((capture, scan)) - targets.append(scan.name) - return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + nodes.append(restore) + restore_output = paths.cas(restore.uid).output + for project_name, project, source in projects: + if project_name == "leak-probe" and architecture != "x64": + continue + name = dependency_node_name( + root, architecture, project_name, source, name_qualifier + ) + files = _project_files(root, project_name, project, source) + prior = previous.get(name) + if prior is not None: + files.update(dependency_inputs(root, restore_output, source, prior)) + capture = clang_factory.make( + "clang-command.ps1", + name.replace("discover-dependencies-", "capture-clang-command-", 1), + "slot", + _compile_variables( + toolchain, + project, + source, + restore_output, + configuration, + platform, + ) | {"llvm_dir": str(toolchain.llvm_dir)}, + files=files, + dependencies=(restore,), + config={"architecture": architecture, "source_namespace": namespace}, + ) + command_file = paths.cas(capture.uid).output / "compile-command.json" + scan = python_action( + scan_factory, + name, + "core.clang_dependencies", + ( + "scan", + str(source), + str(command_file), + str(scanner.path), + str(clang.path), + ), + (capture,), + pool="slot", + environment=environment, + identity=_exact_identity("clang_scan_deps", scanner), + config={"architecture": architecture, "source_namespace": namespace}, + ) + nodes.extend((capture, scan)) + targets.append(scan.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + base = create({}) + previous = _manifest_index( + root, {name: base.node(name).uid for name in base.targets} + ) + return create(previous) if previous else base def analysis_discovery_slice( @@ -333,8 +385,10 @@ def load_dependency_manifests(repository: Path, discovery: Graph) -> dict[str, b paths, manifests = BuildPaths(repository.resolve(strict=True)), {} for name in discovery.targets: node = discovery.node(name) - cas = paths.cas(node.uid, node.name) - manifest = cas.output / "dependencies.json" + cas = paths.cas(node.uid) + manifest = paths.require_confined( + cas.output / "dependencies.json", paths.cas_root + ) if not cas.touch.is_file() or cas.touch.stat().st_size or not manifest.is_file(): raise FileNotFoundError(f"dependency discovery is incomplete: {name}") manifests[name] = manifest.read_bytes() @@ -378,12 +432,11 @@ def analysis_slice( """Analyze compiler-discovered TU inputs, normalize, merge, and gate.""" root = repository.resolve(strict=True) - factory = NodeFactory(TemplateRenderer(_BUILD_ROOT / "templates"), root, - dict(toolchain.identity), tool_environment(toolchain)) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) paths = BuildPaths(root) def output(node: Node) -> Path: - return paths.cas(node.uid, node.name).output + return paths.cas(node.uid).output nodes, targets, projects = list(discovery.nodes), [], _projects(root) for architecture in architectures: @@ -442,6 +495,12 @@ def output(node: Node) -> Path: ("merge", *(str(output(node) / "renpy.sarif") for node in normalized)), tuple(normalized), pool="misc", + results=(Result( + f"reports/sarif/{architecture}/analysis.sarif", + "sarif", + "application/sarif+json", + "analysis.sarif", + ),), ) gate = python_action( factory, diff --git a/build/graphs/audit.py b/build/graphs/audit.py index bb70ca1..09bdf35 100644 --- a/build/graphs/audit.py +++ b/build/graphs/audit.py @@ -2,114 +2,108 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from collections.abc import Mapping from pathlib import Path -import re -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths -from core.render import TemplateRenderer -from graphs.common import BUILD_ROOT, extend_pools, produced_path, python_action, require_positive_integers, require_tool +from graphs.common import BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, require_positive_integers, require_tool _ARCHITECTURES = {"x86", "x64", "arm64"} -_MODULE = re.compile(r"[a-z0-9][a-z0-9._-]*\Z") - - -@dataclass(frozen=True, slots=True) -class BinaryArtifact: - architecture: str - module: str - producer: Node - path: Path +_MODULES = ("renpy", "rpgmaker", "zanzarah") def audit_graph( repository: Path, upstream: Graph, - artifacts: Iterable[BinaryArtifact], *, + architectures: tuple[str, ...] = ("x64",), + include_leak_probe: bool = False, dumpbin: Path, dumpbin_identity: Mapping[str, str], binskim: Path, binskim_identity: Mapping[str, str], jobs: int = 2, - binskim_jobs: int = 1, ) -> Graph: """Compose independent release-binary audits over a validated upstream graph.""" - require_positive_integers((jobs, binskim_jobs), "audit pool capacities must be positive integers") + require_positive_integers((jobs,), "audit pool capacities must be positive integers") root = repository.resolve(strict=True) paths = BuildPaths(root) + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in _ARCHITECTURES for item in architectures) + ): + raise ValueError("audit architectures must be a unique non-empty supported set") dumpbin = require_tool(dumpbin, "dumpbin") binskim = require_tool(binskim, "BinSkim") - renderer = TemplateRenderer(BUILD_ROOT / "templates") - dump_factory = NodeFactory(renderer, root, dict(dumpbin_identity) | {"path": str(dumpbin)}) - python_factory = NodeFactory(renderer, BUILD_ROOT, {}) + dump_factory = recipe_factory(root, dict(dumpbin_identity) | {"path": str(dumpbin)}) + python_factory = recipe_factory(BUILD_ROOT, {}) audit_nodes: list[Node] = [] targets: list[str] = [] - seen: set[tuple[str, str]] = set() + modules = (("leak-probe",) if include_leak_probe else ()) + _MODULES - for artifact in sorted(tuple(artifacts), key=lambda item: (item.architecture, item.module)): - key = (artifact.architecture, artifact.module) - if key in seen: - raise ValueError(f"duplicate binary artifact: {key}") - seen.add(key) - if artifact.architecture not in _ARCHITECTURES or _MODULE.fullmatch(artifact.module) is None: - raise ValueError(f"invalid binary artifact identity: {key}") - binary = produced_path( - paths, upstream, artifact.producer, artifact.path, - f"binary artifact must be below its exact producer CAS: {artifact.path}", - ) + for architecture in sorted(architectures): + for module in modules: + producer, binary = canonical_artifact( + paths, + upstream, + f"build-{module}-{architecture}-release", + "leak-probe.exe" if module == "leak-probe" else f"{module}.so", + ) - dump_nodes = [] - for mode in ("headers", "dependents", "exports"): - current = dump_factory.make( - "argv.json", - f"audit-dumpbin-{mode}-{artifact.architecture}-{artifact.module}", - "dumpbin", - {"argv": (str(dumpbin), f"/{mode}", str(binary))}, - files={}, - dependencies=(artifact.producer,), - config={"action": mode, "architecture": artifact.architecture, "module": artifact.module}, + dump_nodes = [] + for mode in ("headers", "dependents", "exports"): + current = dump_factory.make( + "argv.json", + f"audit-dumpbin-{mode}-{architecture}-{module}", + "slot", + {"argv": (str(dumpbin), f"/{mode}", str(binary))}, + files={}, + dependencies=(producer,), + config={"action": mode, "architecture": architecture, "module": module}, + ) + dump_nodes.append(current) + pe_gate = python_action( + python_factory, + f"audit-pe-{architecture}-{module}", + "core.binary_audit", + ( + "pe", architecture, + *(str(paths.cas(node.uid).log) for node in dump_nodes), + ), + tuple(dump_nodes), + pool="slot", + ) + binskim_run = python_action( + python_factory, + f"audit-binskim-run-{architecture}-{module}", + "core.binary_audit", + ("run-binskim", str(binskim), str(binary)), + (producer,), + pool="slot", + identity=dict(binskim_identity) | {"path": str(binskim)}, + config={"action": "run-binskim", "architecture": architecture, "module": module}, + results=(Result( + f"reports/sarif/{architecture}/binskim-{module}.sarif", + "sarif", + "application/sarif+json", + "binskim.sarif", + ),), + ) + report = paths.cas(binskim_run.uid).output / "binskim.sarif" + binskim_gate = python_action( + python_factory, + f"audit-binskim-{architecture}-{module}", + "core.binary_audit", + ("binskim", str(report)), + (binskim_run,), + pool="slot", ) - dump_nodes.append(current) - pe_gate = python_action( - python_factory, - f"audit-pe-{artifact.architecture}-{artifact.module}", - "core.binary_audit", - ( - "pe", artifact.architecture, - *(str(paths.cas(node.uid, node.name).log) for node in dump_nodes), - ), - tuple(dump_nodes), - pool="audit", - ) - binskim_run = python_action( - python_factory, - f"audit-binskim-run-{artifact.architecture}-{artifact.module}", - "core.binary_audit", - ("run-binskim", str(binskim), str(binary)), - (artifact.producer,), - pool="binskim", - identity=dict(binskim_identity) | {"path": str(binskim)}, - config={"action": "run-binskim", "architecture": artifact.architecture, "module": artifact.module}, - ) - report = paths.cas(binskim_run.uid, binskim_run.name).output / "binskim.sarif" - binskim_gate = python_action( - python_factory, - f"audit-binskim-{artifact.architecture}-{artifact.module}", - "core.binary_audit", - ("binskim", str(report)), - (binskim_run,), - pool="audit", - ) - audit_nodes.extend((*dump_nodes, pe_gate, binskim_run, binskim_gate)) - targets.extend((pe_gate.name, binskim_gate.name)) + audit_nodes.extend((*dump_nodes, pe_gate, binskim_run, binskim_gate)) + targets.extend((pe_gate.name, binskim_gate.name)) - if not seen: - raise ValueError("audit graph requires at least one binary artifact") - pools = extend_pools(upstream, {"audit": jobs, "binskim": binskim_jobs, "dumpbin": jobs}) + pools = extend_pools(upstream, {"slot": jobs}) return Graph(upstream.nodes + tuple(audit_nodes), tuple(targets), pools) diff --git a/build/graphs/common.py b/build/graphs/common.py index 6e80fc3..78c4f3f 100644 --- a/build/graphs/common.py +++ b/build/graphs/common.py @@ -7,18 +7,17 @@ import os from pathlib import Path import sys -import xml.etree.ElementTree as ET -from core.graph import Graph, Node +from core.graph import Graph, Node, Result from core.node import NodeFactory from core.paths import BuildPaths +from core.render import TemplateRenderer BUILD_ROOT = Path(__file__).resolve().parents[1] PROJECTS = ("renpy", "rpgmaker", "zanzarah", "tests") BINARIES = {**{name: f"{name}.so" for name in PROJECTS[:-1]}, "tests": "tests.exe"} PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"} -_MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" _PROJECT_PREFIX = "$(RepositoryRoot)" COMMON_PROJECT_INPUTS = ( "build/ObserverProjectConfigurations.props", @@ -27,6 +26,14 @@ ) +def recipe_factory( + cwd: Path, + identity: dict[str, str], + environment: tuple[tuple[str, str], ...] = (), +) -> NodeFactory: + return NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment) + + def require_positive_integers(values: Iterable[object], message: str) -> None: if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in values): raise ValueError(message) @@ -54,17 +61,11 @@ def extend_pools(upstream: Graph, additions: Mapping[str, int]) -> dict[str, int return pools -def produced_path(paths: BuildPaths, upstream: Graph, producer: Node, candidate: Path, message: str) -> Path: - try: - if upstream.node(producer.name) != producer: - raise ValueError("producer does not match upstream") - output = paths.cas(producer.uid, producer.name).output - current = paths.require_confined(candidate, paths.cas_root) - except ValueError as error: - raise ValueError(message) from error - if current == output or not current.is_relative_to(output): - raise ValueError(message) - return current +def canonical_artifact( + paths: BuildPaths, upstream: Graph, producer_name: str, relative_path: str +) -> tuple[Node, Path]: + producer = upstream.node(producer_name) + return producer, paths.cas(producer.uid).output / relative_path def require_ancestor(upstream: Graph, producer: Node, ancestor: str, message: str) -> None: @@ -99,22 +100,6 @@ def project_path(repository: Path, value: str, project: Path) -> str: return path.resolve(strict=True).relative_to(repository).as_posix() -def project_inputs(repository: Path, project: Path) -> tuple[str, ...]: - inputs = list(COMMON_PROJECT_INPUTS) + [project.relative_to(repository).as_posix()] - for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ModuleDefinitionFile"): - if item.text: - inputs.append(project_path(repository, item.text.strip(), project)) - return tuple(dict.fromkeys(inputs)) - - -def project_sources(repository: Path, project: Path) -> tuple[Path, ...]: - return tuple( - repository / project_path(repository, item.get("Include", ""), project) - for item in ET.parse(project).getroot().iter(f"{_MSBUILD_NS}ClCompile") - if item.get("Include") - ) - - def tool_environment(toolchain: object, *, prepend_path: Path | None = None, extra: tuple[tuple[str, str], ...] = ()) -> tuple[tuple[str, str], ...]: values = {key.casefold(): (key, value) for key, value in toolchain.environment} @@ -162,16 +147,18 @@ def restore_node(repository: Path, toolchain: object, factory: NodeFactory, arch def python_action(factory: NodeFactory, name: str, module: str, arguments: tuple[str, ...], - dependencies: tuple[Node, ...], *, pool: str, - environment: tuple[tuple[str, str], ...] = (), files: Mapping[str, bytes] | None = None, - identity: Mapping[str, str] | None = None, - config: Mapping[str, str] | None = None) -> Node: + dependencies: tuple[Node, ...], *, pool: str, + environment: tuple[tuple[str, str], ...] = (), files: Mapping[str, bytes] | None = None, + identity: Mapping[str, str] | None = None, + config: Mapping[str, str] | None = None, + results: tuple[Result, ...] = ()) -> Node: executable = str(Path(sys.executable).resolve()) source = BUILD_ROOT.joinpath(*module.split(".")).with_suffix(".py") return factory.make( "argv.json", name, pool, {"argv": (executable, "-m", module) + arguments}, files={source.relative_to(BUILD_ROOT.parent).as_posix(): source.read_bytes()} | dict(files or {}), dependencies=dependencies, + results=results, identity=dict(identity or {}) | {"python": sys.version, "python_executable": executable}, config={"action": arguments[0], "platform": "windows"} | dict(config or {}), environment=environment, diff --git a/build/graphs/coverage.py b/build/graphs/coverage.py index b57b240..8e25a66 100644 --- a/build/graphs/coverage.py +++ b/build/graphs/coverage.py @@ -2,34 +2,27 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from collections.abc import Mapping from pathlib import Path -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths from core.quality_tools import resolve_llvm, resolve_tool -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain from graphs.common import ( BINARIES, BUILD_ROOT, + canonical_artifact, extend_pools, prefixed_identity, - produced_path, python_action, + recipe_factory, require_ancestor, require_positive_integers, require_tool, tool_environment, ) -from graphs.instrumented import ( - InstrumentedArtifact, - InstrumentedVariant, - instrumented_build_slice, - instrumented_dependency_discovery_slice, -) +from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice _ARCHITECTURES = {"x86", "x64", "arm64"} @@ -39,54 +32,40 @@ ) -@dataclass(frozen=True, slots=True) -class CoverageArtifact: - architecture: str - name: str - producer: Node - path: Path - - -def _artifacts( +def _builds( paths: BuildPaths, upstream: Graph, - artifacts: Iterable[CoverageArtifact | InstrumentedArtifact], -) -> dict[str, dict[str, CoverageArtifact | InstrumentedArtifact]]: - selected: dict[str, dict[str, CoverageArtifact | InstrumentedArtifact]] = {} - for artifact in artifacts: - key = (artifact.architecture, artifact.name) - if artifact.architecture not in _ARCHITECTURES or artifact.name not in BINARIES: - raise ValueError(f"invalid coverage artifact identity: {key}") - group = selected.setdefault(artifact.architecture, {}) - if artifact.name in group: - raise ValueError(f"duplicate coverage artifact: {key}") - expected_producer = f"build-{artifact.name}-{artifact.architecture}-coverage" - message = f"coverage artifact must use its exact producer CAS: {artifact.path}" - if artifact.producer.name != expected_producer: - raise ValueError(message) - binary = produced_path(paths, upstream, artifact.producer, artifact.path, message) - producer_output = paths.cas(artifact.producer.uid, artifact.producer.name).output - if binary != producer_output / BINARIES[artifact.name]: - raise ValueError(f"coverage artifact must use its exact producer CAS: {binary}") - restore = f"restore-vcpkg-{artifact.architecture}" - require_ancestor( - upstream, artifact.producer, restore, - f"coverage build requires {restore} as a restore ancestor", - ) - group[artifact.name] = artifact - if not selected: - raise ValueError("coverage graph requires at least one complete artifact set") - for architecture, group in selected.items(): - if group.keys() != BINARIES.keys(): - raise ValueError(f"coverage {architecture} requires the complete artifact set") + architectures: tuple[str, ...], +) -> dict[str, dict[str, tuple[Node, Path]]]: + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in _ARCHITECTURES for item in architectures) + ): + raise ValueError("coverage architectures must be a unique non-empty supported set") + selected = {} + for architecture in architectures: + group = {} + for name, filename in BINARIES.items(): + producer, path = canonical_artifact( + paths, upstream, f"build-{name}-{architecture}-coverage", filename + ) + group[name] = (producer, path) + restore = f"restore-vcpkg-{architecture}" + for producer, _path in group.values(): + require_ancestor( + upstream, producer, restore, + f"coverage build requires {restore} as a restore ancestor", + ) + selected[architecture] = group return selected def coverage_artifact_graph( repository: Path, upstream: Graph, - artifacts: Iterable[CoverageArtifact | InstrumentedArtifact], *, + architectures: tuple[str, ...], pwsh: Path, pwsh_identity: Mapping[str, str], llvm_profdata: Path, @@ -118,31 +97,30 @@ def coverage_artifact_graph( pwsh = require_tool(pwsh, "PowerShell") llvm_profdata = require_tool(llvm_profdata, "llvm-profdata") llvm_cov = require_tool(llvm_cov, "llvm-cov") - selected = _artifacts(paths, upstream, artifacts) - renderer = TemplateRenderer(BUILD_ROOT / "templates") + selected = _builds(paths, upstream, architectures) pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) - shard_factory = NodeFactory(renderer, root, pwsh_id, environment) - merge_factory = NodeFactory( - renderer, root, + shard_factory = recipe_factory(root, pwsh_id, environment) + merge_factory = recipe_factory( + root, pwsh_id | prefixed_identity("llvm_profdata", llvm_profdata, llvm_profdata_identity), environment, ) - report_factory = NodeFactory( - renderer, root, + report_factory = recipe_factory( + root, pwsh_id | prefixed_identity("llvm_cov", llvm_cov, llvm_cov_identity), environment, ) - gate_factory = NodeFactory(renderer, BUILD_ROOT, {}) + gate_factory = recipe_factory(BUILD_ROOT, {}) nodes: list[Node] = [] targets: list[str] = [] def output(node: Node, filename: str = "") -> Path: - return paths.cas(node.uid, node.name).output / filename + return paths.cas(node.uid).output / filename for architecture, group in sorted(selected.items()): - builds = tuple(group[name].producer for name in BINARIES) + builds = tuple(group[name][0] for name in BINARIES) copies = tuple( - {"name": BINARIES[name], "source": str(group[name].path)} for name in BINARIES + {"name": BINARIES[name], "source": str(group[name][1])} for name in BINARIES ) shards = tuple( shard_factory.make( @@ -154,6 +132,12 @@ def output(node: Node, filename: str = "") -> Path: "shard_count": test_shards, "shard_index": index, }, files={}, dependencies=builds, + results=(Result( + f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml", + "test", + "application/xml", + "tests.xml", + ),), config={ "action": "coverage-test", "architecture": architecture, "shard_count": str(test_shards), "shard_index": str(index), @@ -177,6 +161,12 @@ def output(node: Node, filename: str = "") -> Path: "shard_count": test_shards, "shard_index": index, }, files={}, dependencies=builds, + results=(Result( + f"reports/coverage/cpp/{architecture}/corpus/shard-{index}.xml", + "test", + "application/xml", + "tests.xml", + ),), config={ "action": "coverage-corpus-test", "architecture": architecture, @@ -210,12 +200,18 @@ def output(node: Node, filename: str = "") -> Path: "coverage-report", { "pwsh": str(pwsh), "llvm_cov": str(llvm_cov), - "test_executable": str(group["tests"].path), + "test_executable": str(group["tests"][1]), "profile": str(profile), "ignore_regex": _IGNORED_SOURCES, - "objects": tuple(str(group[name].path) for name in BINARIES if name != "tests"), + "objects": tuple(str(group[name][1]) for name in BINARIES if name != "tests"), "summary_only": summary_only, "report_name": f"coverage.{kind}", }, files={}, dependencies=(merge, *builds), + results=(Result( + f"reports/coverage/cpp/{architecture}/coverage.{kind}", + "coverage", + "application/json" if kind == "json" else "text/plain", + f"coverage.{kind}", + ),), config={"action": f"coverage-{kind}", "architecture": architecture}, ) reports.append(report) @@ -273,7 +269,7 @@ def coverage_graph( """Build instrumented C++ artifacts, run shards, report, and gate coverage.""" variants = tuple(InstrumentedVariant("coverage", item) for item in architectures) - upstream, produced = instrumented_build_slice( + upstream = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -287,7 +283,7 @@ def coverage_graph( return coverage_artifact_graph( repository, upstream, - produced, + architectures=architectures, pwsh=pwsh.path, pwsh_identity=dict(pwsh.identity), llvm_profdata=profdata.path, diff --git a/build/graphs/fuzz.py b/build/graphs/fuzz.py index 8ea6de4..2a349d7 100644 --- a/build/graphs/fuzz.py +++ b/build/graphs/fuzz.py @@ -8,17 +8,15 @@ import re import xml.etree.ElementTree as ET -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain from graphs.analysis import ( clang_dependency_discovery_slice, dependency_inputs, dependency_node_name, ) -from graphs.common import tool_environment +from graphs.common import recipe_factory, tool_environment _BUILD_ROOT = Path(__file__).resolve().parents[1] @@ -137,7 +135,7 @@ def _prior_corpora( paths, result = BuildPaths(repository), {} for target, artifact in indexed.items(): node_name = f"run-fuzz-x64-{target}" - cas = paths.cas(artifact.producer_uid, node_name) + cas = paths.cas(artifact.producer_uid) corpus = paths.require_confined(cas.output / "corpus", paths.cas_root) if not ( cas.entry.is_dir() @@ -182,14 +180,13 @@ def fuzz_graph( root = repository.resolve(strict=True) prior = _prior_corpora(root, prior_corpora) runtime = _runtime(toolchain) - renderer = TemplateRenderer(_BUILD_ROOT / "templates") paths = BuildPaths(root) identity = dict(toolchain.identity) | {"llvm_runtime": str(runtime)} - factory = NodeFactory(renderer, root, identity, tool_environment(toolchain)) + factory = recipe_factory(root, identity, tool_environment(toolchain)) restore = discovery.node("restore-vcpkg-asan-x64") nodes, targets = list(discovery.nodes), [] - restore_output = paths.cas(restore.uid, restore.name).output + restore_output = paths.cas(restore.uid).output for target in selected_targets: max_length = _TARGETS[target] @@ -213,7 +210,7 @@ def fuzz_graph( config={"action": "build", "architecture": "x64", "target": target}, ) seed_dir, seed_files = _seed_files(root, target) - fuzzer = paths.cas(build.uid, build.name).output / executable_name + fuzzer = paths.cas(build.uid).output / executable_name replay = factory.make( "fuzz-replay.ps1", f"replay-fuzz-x64-{target}", @@ -246,6 +243,20 @@ def fuzz_graph( "max_length": max_length, "seconds": seconds, "prior_corpus": prior_path, }, files=seed_files | prior_files, dependencies=(replay,), + results=( + Result( + f"reports/fuzz/x64/{target}/status.txt", + "fuzz", "text/plain", "status.txt", + ), + Result( + f"reports/fuzz/x64/{target}/corpus", + "corpus", "application/octet-stream", "corpus", + ), + Result( + f"reports/fuzz/x64/{target}/artifacts", + "evidence", "application/octet-stream", "artifacts", + ), + ), config={"action": "fuzz", "target": target, "max_length": str(max_length), "seconds": str(seconds), "run_nonce": run_nonce, "asan_options": _ASAN_OPTIONS, "prior_corpus_uid": prior_uid}, @@ -260,7 +271,7 @@ def fuzz_graph( f"fuzz-x64-{target}", "fuzz", {"pwsh": str(toolchain.pwsh), - "status": str(paths.cas(run.uid, run.name).output / "status.txt")}, + "status": str(paths.cas(run.uid).output / "status.txt")}, files={}, dependencies=(run,), config={"action": "fuzz-gate", "target": target}, ) diff --git a/build/graphs/instrumented.py b/build/graphs/instrumented.py index 2a5edc8..e3bf444 100644 --- a/build/graphs/instrumented.py +++ b/build/graphs/instrumented.py @@ -6,25 +6,20 @@ from dataclasses import dataclass, field from pathlib import Path -from core.graph import Graph, GraphError, Node, merge_graphs -from core.node import NodeFactory +from core.graph import Graph, GraphError, merge_graphs from core.paths import BuildPaths from core.quality_tools import UBSAN_LIBRARIES, resolve_llvm -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain from graphs.analysis import ( clang_dependency_discovery_slice, dependency_discovery_slice, - dependency_inputs, - dependency_node_name, + project_build, + project_inventory, ) from graphs.common import ( - BINARIES, - BUILD_ROOT, PLATFORMS, PROJECTS, - project_inputs, - project_sources, + recipe_factory, require_positive_integers, tool_environment, ) @@ -45,15 +40,6 @@ def configuration(self) -> str: return _CONFIGURATIONS.get(self.kind, self.kind) -@dataclass(frozen=True, slots=True) -class InstrumentedArtifact: - kind: str - architecture: str - name: str - producer: Node - path: Path - - def _variants( variants: tuple[InstrumentedVariant, ...], jobs: int ) -> tuple[InstrumentedVariant, ...]: @@ -125,74 +111,6 @@ def instrumented_dependency_discovery_slice( raise ValueError(f"conflicting canonical node: {name}") from error -def _build( - repository: Path, - toolchain: MsvcToolchain, - factory: NodeFactory, - discovery: Graph, - manifests: Mapping[str, bytes], - variant: InstrumentedVariant, - project_name: str, - clang_identity: Mapping[str, str], -) -> Node: - project = repository / "build/projects" / f"{project_name}.vcxproj" - files = { - name: (repository / name).read_bytes() - for name in project_inputs(repository, project) - } - dependencies = [] - restore_name = ( - f"restore-vcpkg-asan-{variant.architecture}" - if variant.kind == "asan" - else f"restore-vcpkg-{variant.architecture}" - ) - restore = discovery.node(restore_name) - restore_output = BuildPaths(repository).cas(restore.uid, restore.name).output - for source in project_sources(repository, project): - name = dependency_node_name( - repository, variant.architecture, project_name, source, variant.kind - ) - discovered = discovery.node(name) - dependencies.append(discovered) - try: - manifest = manifests[name] - except KeyError as error: - raise ValueError(f"missing dependency manifest: {name}") from error - files.update(dependency_inputs(repository, restore_output, source, manifest)) - - runtime = variant.llvm_runtime.resolve(strict=True) if variant.llvm_runtime else None - identity = dict(toolchain.identity) - if variant.kind in {"coverage", "ubsan"}: - identity.update(clang_identity) - if runtime is not None: - identity.update( - {f"llvm_runtime.{key}": value for key, value in variant.runtime_identity.items()} - ) - identity["llvm_runtime.path"] = str(runtime) - return factory.make( - "instrumented-build.ps1", - f"build-{project_name}-{variant.architecture}-{variant.kind}", - "slot", - { - "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), - "project": str(project), "target": "Build", - "configuration": variant.configuration, - "platform": PLATFORMS[variant.architecture], - "vcpkg_root": str(toolchain.vcpkg_root), - "vcpkg_installed": str(restore_output), - "llvm_dir": str(toolchain.llvm_dir) if variant.kind in {"coverage", "ubsan"} else "", - "llvm_runtime": str(runtime) if runtime else "", - }, - files=files, - dependencies=tuple(dependencies), - identity=identity, - config={ - "action": "build", "kind": variant.kind, - "architecture": variant.architecture, "project": project_name, - }, - ) - - def instrumented_build_slice( repository: Path, toolchain: MsvcToolchain, @@ -201,7 +119,7 @@ def instrumented_build_slice( manifests: Mapping[str, bytes], variants: tuple[InstrumentedVariant, ...], jobs: int = 2, -) -> tuple[Graph, tuple[InstrumentedArtifact, ...]]: +) -> Graph: """Build independently cacheable modules/tests from completed discovery.""" selected = _variants(variants, jobs) @@ -210,8 +128,7 @@ def instrumented_build_slice( if discovery.pools.get("slot") != jobs: raise ValueError("conflicting slot pool capacity") root = repository.resolve(strict=True) - factory = NodeFactory( - TemplateRenderer(BUILD_ROOT / "templates"), + factory = recipe_factory( root, dict(toolchain.identity), tool_environment(toolchain), @@ -223,32 +140,47 @@ def instrumented_build_slice( clang_identity = { f"clang_cl.{key}": value for key, value in clang.identity } - builds, artifacts = [], [] + builds = [] + projects = project_inventory(root, PROJECTS) for variant in selected: - for project in PROJECTS: - build = _build( - root, - toolchain, - factory, - discovery, - manifests, - variant, - project, - clang_identity, + restore_name = ( + f"restore-vcpkg-asan-{variant.architecture}" + if variant.kind == "asan" + else f"restore-vcpkg-{variant.architecture}" + ) + restore = discovery.node(restore_name) + restore_output = paths.cas(restore.uid).output + runtime = variant.llvm_runtime.resolve(strict=True) if variant.llvm_runtime else None + identity = dict(toolchain.identity) + if variant.kind in {"coverage", "ubsan"}: + identity.update(clang_identity) + if runtime is not None: + identity.update({ + f"llvm_runtime.{key}": value + for key, value in variant.runtime_identity.items() + }) + identity["llvm_runtime.path"] = str(runtime) + for project in projects: + build = project_build( + root, factory, discovery, manifests, restore_output, project, + variant.architecture, variant.kind, "instrumented-build.ps1", { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project.path), "target": "Build", + "configuration": variant.configuration, + "platform": PLATFORMS[variant.architecture], + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + "llvm_dir": str(toolchain.llvm_dir) + if variant.kind in {"coverage", "ubsan"} else "", + "llvm_runtime": str(runtime) if runtime else "", + }, identity=identity, config={ + "action": "build", "kind": variant.kind, + "architecture": variant.architecture, "project": project.name, + }, ) builds.append(build) - artifacts.append( - InstrumentedArtifact( - variant.kind, - variant.architecture, - project, - build, - paths.cas(build.uid, build.name).output / BINARIES[project], - ) - ) - graph = Graph( + return Graph( discovery.nodes + tuple(builds), tuple(build.name for build in builds), discovery.pools, ) - return graph, tuple(artifacts) diff --git a/build/graphs/leak.py b/build/graphs/leak.py index b57c549..f4b9989 100644 --- a/build/graphs/leak.py +++ b/build/graphs/leak.py @@ -2,16 +2,13 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Mapping from pathlib import Path -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths -from core.render import TemplateRenderer -from graphs.audit import BinaryArtifact from graphs.common import ( - BUILD_ROOT, extend_pools, produced_path, python_action, required_audit_gates, + BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates, require_positive_integers, require_tool, ) @@ -21,30 +18,9 @@ _BINARIES = {"leak-probe": "leak-probe.exe", "renpy": "renpy.so", "rpgmaker": "rpgmaker.so", "zanzarah": "zanzarah.so"} -def _artifacts(paths: BuildPaths, upstream: Graph, artifacts: Iterable[BinaryArtifact]) -> dict[str, BinaryArtifact]: - selected: dict[str, BinaryArtifact] = {} - for artifact in artifacts: - if artifact.architecture != "x64" or artifact.module not in _BINARIES: - raise ValueError(f"invalid leak binary artifact: {(artifact.architecture, artifact.module)}") - if artifact.module in selected: - raise ValueError(f"duplicate leak binary artifact: {artifact.module}") - binary = produced_path( - paths, upstream, artifact.producer, artifact.path, - f"leak binary must be below its exact producer CAS: {artifact.path}", - ) - if binary.name != _BINARIES[artifact.module]: - raise ValueError(f"leak binary must be below its exact producer CAS: {binary}") - selected[artifact.module] = artifact - missing = _BINARIES.keys() - selected.keys() - if missing: - raise ValueError(f"missing leak binary artifacts: {', '.join(sorted(missing))}") - return selected - - def leak_graph( repository: Path, upstream: Graph, - artifacts: Iterable[BinaryArtifact], *, umdh: Path, umdh_identity: Mapping[str, str], @@ -54,12 +30,10 @@ def leak_graph( windows: int = 3, tolerance_bytes: int = 0, jobs: int = 4, - session_jobs: int = 2, - diff_jobs: int = 4, ) -> Graph: """Add one demand target per leak mode/scenario without a monolithic gate.""" - capacities = (jobs, session_jobs, diff_jobs) + capacities = (jobs,) counts = (warmup, iterations, windows) require_positive_integers(capacities, "leak pool capacities must be positive integers") require_positive_integers(counts, "leak measurement counts must be positive integers") @@ -73,60 +47,89 @@ def leak_graph( root = repository.resolve(strict=True) paths = BuildPaths(root) umdh = require_tool(umdh, "UMDH") - selected = _artifacts(paths, upstream, artifacts) - renderer = TemplateRenderer(BUILD_ROOT / "templates") - factory = NodeFactory(renderer, BUILD_ROOT, {}) + selected = { + name: canonical_artifact( + paths, upstream, f"build-{name}-x64-release", filename + ) + for name, filename in _BINARIES.items() + } + factory = recipe_factory(BUILD_ROOT, {}) umdh_signature = {f"umdh.{key}": value for key, value in umdh_identity.items()} | {"umdh.path": str(umdh)} - def action(name: str, pool: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], - *, identity: Mapping[str, str] | None = None, config: Mapping[str, str] | None = None) -> Node: + def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], + *, identity: Mapping[str, str] | None = None, + config: Mapping[str, str] | None = None, + results: tuple[Result, ...] = ()) -> Node: return python_action( factory, name, "core.leak", arguments, dependencies, - pool=pool, identity=identity, config=config, + pool="slot", identity=identity, config=config, results=results, ) ordered = tuple(selected[name] for name in _BINARIES) - setup_dependencies = (selected["leak-probe"].producer,) + tuple( - dependency for item in ordered if item.module != "leak-probe" - for dependency in (item.producer, *required_audit_gates(upstream, item.producer, "x64", item.module)) + setup_dependencies = (selected["leak-probe"][0],) + tuple( + dependency for name, (producer, _path) in zip(_BINARIES, ordered, strict=True) + if name != "leak-probe" + for dependency in (producer, *required_audit_gates(upstream, producer, "x64", name)) ) setup = action( - "leak-setup-x64-release", "leak", ("setup", *(str(item.path) for item in ordered)), setup_dependencies, + "leak-setup-x64-release", ("setup", *(str(path) for _producer, path in ordered)), setup_dependencies, config={"architecture": "x64", "configuration": "Release"}, ) - setup_output = paths.cas(setup.uid, setup.name).output + setup_output = paths.cas(setup.uid).output nodes: list[Node] = [setup] targets: list[str] = [] for mode in LEAK_MODES: for scenario in LEAK_SCENARIOS: stem = f"{mode}-{scenario}" - preflight = action(f"leak-preflight-{stem}", "leak", - ("preflight", str(setup_output), mode, scenario), (setup,)) - capture = action(f"leak-capture-{stem}", "leak-session", + result_root = f"reports/leak/x64/{mode}/{scenario}" + preflight = action(f"leak-preflight-{stem}", + ("preflight", str(setup_output), mode, scenario), (setup,), + results=(Result( + f"{result_root}/preflight.json", "leak", + "application/json", "preflight.json", + ),)) + capture = action(f"leak-capture-{stem}", ("capture", str(setup_output), str(umdh), mode, scenario, str(warmup), str(iterations), str(windows)), - (preflight,), identity=umdh_signature, config={"run_nonce": run_nonce}) - capture_output = paths.cas(capture.uid, capture.name).output + (preflight,), identity=umdh_signature, config={"run_nonce": run_nonce}, + results=( + Result(f"{result_root}/capture.json", "leak", "application/json", "capture.json"), + Result(f"{result_root}/snapshots", "leak-snapshots", "application/octet-stream", "snapshots"), + Result(f"{result_root}/probe.stderr.log", "log", "text/plain", "probe.stderr.log"), + )) + capture_output = paths.cas(capture.uid).output snapshots = capture_output / "snapshots" specs = [(f"window-{index}", snapshots / f"window-{index}.txt", snapshots / f"window-{index + 1}.txt") for index in range(1, windows)] specs.append(("overall", snapshots / "window-1.txt", snapshots / f"window-{windows}.txt")) diffs = tuple( - action(f"leak-diff-{stem}-{label}", "leak-diff", + action(f"leak-diff-{stem}-{label}", ("diff", str(umdh), str(setup_output), label, str(before), str(after)), - (capture,), identity=umdh_signature) + (capture,), identity=umdh_signature, + results=( + Result(f"{result_root}/diffs/{label}.json", "leak-diff", "application/json", "diff.json"), + Result(f"{result_root}/diffs/{label}.txt", "leak-diff", "text/plain", "report.txt"), + )) for label, before, after in specs ) - judge = action(f"leak-judge-{stem}", "leak", + summary = action(f"leak-summary-{stem}", ( - "judge", mode, scenario, str(warmup), str(iterations), str(windows), + "summarize", mode, scenario, str(warmup), str(iterations), str(windows), str(tolerance_bytes), *(value for (label, _before, _after), item in zip(specs, diffs, strict=True) - for value in (label, str(paths.cas(item.uid, item.name).output / "diff.json"))), - ), diffs) - nodes.extend((preflight, capture, *diffs, judge)) - targets.append(judge.name) + for value in (label, str(paths.cas(item.uid).output / "diff.json"))), + ), diffs, results=(Result( + f"{result_root}/summary.json", "leak-summary", + "application/json", "summary.json", + ),)) + gate = action( + f"leak-gate-{stem}", + ("gate", str(paths.cas(summary.uid).output / "summary.json")), + (summary,), + ) + nodes.extend((preflight, capture, *diffs, summary, gate)) + targets.append(gate.name) - pools = extend_pools(upstream, {"leak": jobs, "leak-session": session_jobs, "leak-diff": diff_jobs}) + pools = extend_pools(upstream, {"slot": jobs}) return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools) diff --git a/build/graphs/native.py b/build/graphs/native.py index e48c3b4..7424c94 100644 --- a/build/graphs/native.py +++ b/build/graphs/native.py @@ -5,36 +5,26 @@ from collections.abc import Mapping from pathlib import Path -from core.graph import Graph, Node, merge_graphs +from core.graph import Graph, Node, Result, merge_graphs from core.node import NodeFactory from core.paths import BuildPaths -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain from graphs.analysis import ( dependency_discovery_slice, - dependency_inputs, - dependency_node_name, + project_build, + project_inventory, ) from graphs.common import ( BINARIES, - BUILD_ROOT, - COMMON_PROJECT_INPUTS, PLATFORMS, PROJECTS, - project_inputs, - project_path, - project_sources, + recipe_factory, require_positive_integers, tool_environment, ) _CONFIGURATIONS = ("Debug", "Release") -_COMMON_INPUTS = COMMON_PROJECT_INPUTS -_project_inputs = project_inputs -_relative = project_path - - def _validate_matrix( jobs: int, architectures: tuple[str, ...], configurations: tuple[str, ...] ) -> None: @@ -76,57 +66,6 @@ def native_dependency_discovery_slice( return merge_graphs(*graphs) -def _build( - repository: Path, - toolchain: MsvcToolchain, - factory: NodeFactory, - discovery: Graph, - manifests: Mapping[str, bytes], - restore_output: Path, - project_name: str, - architecture: str, - configuration: str, - platform: str, -) -> Node: - project = repository / "build/projects" / f"{project_name}.vcxproj" - inputs = project_inputs(repository, project) - files = {path: (repository / path).read_bytes() for path in inputs} - dependencies = [] - for source in project_sources(repository, project): - name = dependency_node_name( - repository, architecture, project_name, source, configuration.lower() - ) - dependencies.append(discovery.node(name)) - try: - manifest = manifests[name] - except KeyError as error: - raise ValueError(f"missing dependency manifest: {name}") from error - files.update(dependency_inputs(repository, restore_output, source, manifest)) - return factory.make( - "native-build.ps1", - f"build-{project_name}-{architecture}-{configuration.lower()}", - "slot", - { - "pwsh": str(toolchain.pwsh), - "msbuild": str(toolchain.msbuild), - "project": str(project), - "target": "Build", - "configuration": configuration, - "platform": platform, - "vcpkg_root": str(toolchain.vcpkg_root), - "vcpkg_installed": str(restore_output), - }, - files=files, - dependencies=tuple(dependencies), - config={ - "action": "build", - "architecture": architecture, - "configuration": configuration, - "project": project_name, - }, - ) - - def _test_shard( repository: Path, toolchain: MsvcToolchain, @@ -143,11 +82,12 @@ def _test_shard( artifacts = [ { "name": BINARIES[project], - "source": str(paths.cas(node.uid, node.name).output / BINARIES[project]), + "source": str(paths.cas(node.uid).output / BINARIES[project]), } for project, node in zip(PROJECTS, builds, strict=True) ] prefix = "corpus" if corpus is not None else "test" + result_scope = "corpus" if corpus is not None else "unit" name = f"{prefix}-shard-{architecture}-{configuration.lower()}-{shard_index}" config = { "action": "test", @@ -174,6 +114,13 @@ def _test_shard( }, files={}, dependencies=builds, + results=(Result( + f"reports/tests/{architecture}/{configuration.lower()}/{result_scope}/" + f"shard-{shard_index}.xml", + "test", + "application/xml", + "tests.xml", + ),), config=config, **options, ) @@ -212,49 +159,37 @@ def native_graph( raise ValueError("native dependency discovery is required") root = repository.resolve(strict=True) - renderer = TemplateRenderer(BUILD_ROOT / "templates") - factory = NodeFactory( - renderer, root, dict(toolchain.identity), tool_environment(toolchain) - ) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) paths = BuildPaths(root) nodes: list[Node] = list(discovery.nodes) targets: list[str] = [] runnable = set(runnable_architectures) + projects = project_inventory(root, PROJECTS) for architecture in architectures: restore = discovery.node(f"restore-vcpkg-{architecture}") - restore_output = paths.cas(restore.uid, restore.name).output + restore_output = paths.cas(restore.uid).output for configuration in configurations: - builds = tuple( - _build( - root, - toolchain, - factory, - discovery, - manifests, - restore_output, - project, - architecture, - configuration, - PLATFORMS[architecture], + def build(project): + return project_build( + root, factory, discovery, manifests, restore_output, project, + architecture, configuration.lower(), "native-build.ps1", { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project.path), "target": "Build", + "configuration": configuration, "platform": PLATFORMS[architecture], + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + }, config={ + "action": "build", "architecture": architecture, + "configuration": configuration, "project": project.name, + }, ) - for project in PROJECTS - ) + + builds = tuple(build(project) for project in projects) nodes.extend(builds) leak_probe = None if include_leak_probe and architecture == "x64" and configuration == "Release": - leak_probe = _build( - root, - toolchain, - factory, - discovery, - manifests, - restore_output, - "leak-probe", - architecture, - configuration, - PLATFORMS[architecture], - ) + leak_probe = build(project_inventory(root, ("leak-probe",))[0]) nodes.append(leak_probe) if architecture in runnable: diff --git a/build/graphs/package.py b/build/graphs/package.py index f409d54..59f4c8c 100644 --- a/build/graphs/package.py +++ b/build/graphs/package.py @@ -2,48 +2,26 @@ from __future__ import annotations -from collections.abc import Iterable -from dataclasses import dataclass from pathlib import Path -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.package import ARCHITECTURES, LICENSES, MODULES from core.paths import BuildPaths -from core.render import TemplateRenderer from graphs.common import ( - BUILD_ROOT, extend_pools, produced_path, python_action, required_audit_gates, require_positive_integers, + BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates, require_positive_integers, ) -@dataclass(frozen=True, slots=True) -class PackageArtifact: - architecture: str - module: str - producer: Node - binary: Path - symbols: Path - - -@dataclass(frozen=True, slots=True) -class PackageSmokeArtifact: - architecture: str - producer: Node - executable: Path - - def package_outputs(repository: Path, graph: Graph) -> tuple[Path, ...]: - """Return exact CAS ZIP paths from a composed package graph, without materializing copies.""" - - paths, by_name, result = BuildPaths(repository.resolve(strict=True)), {node.name: node for node in graph.nodes}, [] - for architecture in sorted(ARCHITECTURES): - symbols = by_name.get(f"package-symbols-{architecture}") - if symbols is None: - continue - for module in MODULES: - node = graph.node(f"package-archive-{architecture}-{module}") - result.append(paths.cas(node.uid, node.name).output / f"{module}-{architecture}-dll.zip") - result.append(paths.cas(symbols.uid, symbols.name).output / f"observer-modules-{architecture}-pdb.zip") + """Return gated ZIP paths from the final package-manifest producer.""" + + paths = BuildPaths(repository.resolve(strict=True)) + try: + aggregate = graph.node("package-manifest") + except ValueError as error: + raise ValueError("graph has no package outputs") from error + output = paths.cas(aggregate.uid).output + result = [output / item.relative_path for item in aggregate.results if item.kind == "package"] if not result: raise ValueError("graph has no package outputs") return tuple(result) @@ -52,9 +30,9 @@ def package_outputs(repository: Path, graph: Graph) -> tuple[Path, ...]: def package_graph( repository: Path, upstream: Graph, - artifacts: Iterable[PackageArtifact], *, - smoke_tests: Iterable[PackageSmokeArtifact] = (), + architectures: tuple[str, ...], + smoke_architectures: tuple[str, ...] = (), jobs: int = 4, ) -> Graph: """Compose module and symbol packages without artificial cross-architecture edges.""" @@ -62,79 +40,80 @@ def package_graph( require_positive_integers((jobs,), "package jobs must be a positive integer") root = repository.resolve(strict=True) paths = BuildPaths(root) - factory = NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), BUILD_ROOT, {}) + factory = recipe_factory(BUILD_ROOT, {}) def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], - files: dict[str, bytes] | None = None) -> Node: + files: dict[str, bytes] | None = None, + results: tuple[Result, ...] = ()) -> Node: return python_action(factory, name, "core.package", arguments, dependencies, - pool="package", files=files) - - selected = sorted(tuple(artifacts), key=lambda item: (item.architecture, item.module)) - if not selected: - raise ValueError("package graph requires at least one artifact") - - keys = [(item.architecture, item.module) for item in selected] - if len(keys) != len(set(keys)): - raise ValueError("duplicate package artifact") - if any(architecture not in ARCHITECTURES or module not in MODULES for architecture, module in keys): - raise ValueError("invalid package artifact identity") - if len(keys) != len(MODULES) * len({architecture for architecture, _module in keys}): - raise ValueError("each architecture requires the complete module set") + pool="package", files=files, results=results) + + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in ARCHITECTURES for item in architectures) + ): + raise ValueError("package architectures must be a unique non-empty supported set") + if ( + len(smoke_architectures) != len(set(smoke_architectures)) + or any(item not in architectures for item in smoke_architectures) + ): + raise ValueError("package smoke architectures must be a unique package subset") + selected_architectures = tuple(sorted(architectures)) nodes: list[Node] = [] archives: dict[str, Node] = {} validations: dict[str, Node] = {} - symbol_stages = {architecture: [] for architecture in sorted({item.architecture for item in selected})} + symbol_stages = {architecture: [] for architecture in selected_architectures} def output(node: Node, name: str = "") -> Path: - return paths.cas(node.uid, node.name).output / name - - def produced(producer: Node, path: Path, name: str, message: str) -> Path: - current = produced_path(paths, upstream, producer, path, message) - if current != output(producer, name): - raise ValueError(message) - return current - - for artifact in selected: - architecture, module, producer = artifact.architecture, artifact.module, artifact.producer - suffix = f"{architecture}-{module}" - message = "package artifact must use its exact producer CAS" - binary = produced(producer, artifact.binary, f"{module}.so", message) - symbols = produced(producer, artifact.symbols, f"{module}.pdb", message) - gates = required_audit_gates(upstream, producer, architecture, module) - dependencies = (producer, *gates) - - repository_inputs = ( - f"src/modules/{module}/observer_user.ini", - "LICENSE.txt", - *(f"licenses/{name}" for name in LICENSES[module]), - ) - stage = action( - f"package-stage-{suffix}", - ("stage-module", architecture, module, str(binary), str(root)), - dependencies, - {name: (root / name).read_bytes() for name in repository_inputs}, - ) - symbol_stage = action( - f"package-symbol-stage-{suffix}", - ("stage-symbol", architecture, module, str(symbols)), - dependencies, - ) - archive = action( - f"package-archive-{suffix}", - ("archive-module", architecture, module, str(output(stage))), - (stage, *gates), - ) - archive_name = f"{module}-{architecture}-dll.zip" - validation = action( - f"package-validate-{suffix}", - ("validate-module", architecture, module, str(output(archive, archive_name)), str(output(stage))), - (archive, stage), - ) - nodes.extend((stage, symbol_stage, archive, validation)) - archives[archive_name] = archive - validations[archive_name] = validation - symbol_stages[architecture].append(symbol_stage) + return paths.cas(node.uid).output / name + + for architecture in selected_architectures: + for module in MODULES: + producer, binary = canonical_artifact( + paths, upstream, f"build-{module}-{architecture}-release", f"{module}.so" + ) + suffix = f"{architecture}-{module}" + symbols = paths.cas(producer.uid).output / f"{module}.pdb" + gates = required_audit_gates(upstream, producer, architecture, module) + dependencies = (producer, *gates) + + repository_inputs = ( + f"src/modules/{module}/observer_user.ini", + "LICENSE.txt", + *(f"licenses/{name}" for name in LICENSES[module]), + ) + stage = action( + f"package-stage-{suffix}", + ("stage-module", architecture, module, str(binary), str(root)), + dependencies, + {name: (root / name).read_bytes() for name in repository_inputs}, + ) + symbol_stage = action( + f"package-symbol-stage-{suffix}", + ("stage-symbol", architecture, module, str(symbols)), + dependencies, + ) + archive = action( + f"package-archive-{suffix}", + ("archive-module", architecture, module, str(output(stage))), + (stage, *gates), + ) + archive_name = f"{module}-{architecture}-dll.zip" + validation = action( + f"package-validate-{suffix}", + ("validate-module", architecture, module, str(output(archive, archive_name)), str(output(stage))), + (archive, stage), + results=(Result( + f"reports/package/{architecture}/{module}/validation.json", + "package-validation", "application/json", "validation.json", + ),), + ) + nodes.extend((stage, symbol_stage, archive, validation)) + archives[archive_name] = archive + validations[archive_name] = validation + symbol_stages[architecture].append(symbol_stage) for architecture, stages in sorted(symbol_stages.items()): current = action( @@ -152,36 +131,44 @@ def produced(producer: Node, path: Path, name: str, message: str) -> Path: ("validate-symbols", architecture, str(output(current, archive_name)), *(str(output(stage)) for stage in stages)), (current, *stages), + results=(Result( + f"reports/package/{architecture}/symbols/validation.json", + "package-validation", "application/json", "validation.json", + ),), ) nodes.append(validation) archives[archive_name] = current validations[archive_name] = validation - smokes = sorted(tuple(smoke_tests), key=lambda item: item.architecture) - if len(smokes) != len({smoke.architecture for smoke in smokes}): - raise ValueError("duplicate package smoke architecture") smoke_nodes = [] - for smoke in smokes: - message = "package smoke must use its exact test producer CAS" - if smoke.architecture not in symbol_stages: - raise ValueError(message) - test_executable = produced(smoke.producer, smoke.executable, "tests.exe", message) + for architecture in sorted(smoke_architectures): + test_producer, test_executable = canonical_artifact( + paths, upstream, f"build-tests-{architecture}-release", "tests.exe" + ) for module in MODULES: - archive_name = f"{module}-{smoke.architecture}-dll.zip" + archive_name = f"{module}-{architecture}-dll.zip" archive = archives[archive_name] validation = validations[archive_name] smoke_node = action( - f"package-smoke-{smoke.architecture}-{module}", + f"package-smoke-{architecture}-{module}", ( - "smoke", smoke.architecture, module, + "smoke", architecture, module, str(output(archive, archive_name)), str(test_executable), ), - (validation, smoke.producer), + (validation, test_producer), ) nodes.append(smoke_node) smoke_nodes.append(smoke_node) + package_results = tuple( + Result(f"packages/{architecture}/{name}", "package", "application/zip", name) + for architecture in sorted(symbol_stages) + for name in ( + *(f"{module}-{architecture}-dll.zip" for module in MODULES), + f"observer-modules-{architecture}-pdb.zip", + ) + ) aggregate = action( "package-manifest", ( @@ -189,6 +176,9 @@ def produced(producer: Node, path: Path, name: str, message: str) -> Path: *(str(output(node, name)) for name, node in archives.items()), ), tuple(validations.values()) + tuple(smoke_nodes), + results=package_results + (Result( + "packages/packages.json", "package-manifest", "application/json", "packages.json", + ),), ) nodes.append(aggregate) return Graph(upstream.nodes + tuple(nodes), (aggregate.name,), extend_pools(upstream, {"package": jobs})) diff --git a/build/graphs/python_coverage.py b/build/graphs/python_coverage.py index 86f5536..0051c07 100644 --- a/build/graphs/python_coverage.py +++ b/build/graphs/python_coverage.py @@ -5,10 +5,8 @@ import hashlib from pathlib import Path -from core.graph import Graph -from core.node import NodeFactory -from core.render import TemplateRenderer -from graphs.common import BUILD_ROOT, python_action +from core.graph import Graph, Result +from graphs.common import BUILD_ROOT, python_action, recipe_factory def _inputs(repository: Path, build_root: Path) -> dict[str, bytes]: @@ -44,12 +42,19 @@ def python_coverage_graph(repository: Path) -> Graph: if not coverage.is_file(): raise FileNotFoundError(f"project coverage executable is not a file: {coverage}") digest = _tool_digest(build_root, coverage) - factory = NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), BUILD_ROOT, {}) + factory = recipe_factory(BUILD_ROOT, {}) gate = python_action( factory, "python-coverage", "core.python_coverage", (str(coverage), str(build_root)), (), pool="python-coverage", files=_inputs(root, build_root), identity={"coverage.path": str(coverage), "coverage.sha256": digest}, config={"action": "python-coverage", "coverage": "100-percent-line-and-branch"}, environment=(("PYTHONDONTWRITEBYTECODE", "1"),), + results=( + Result("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"), + Result("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"), + Result("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"), + Result("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"), + Result("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"), + ), ) return Graph((gate,), (gate.name,), {"python-coverage": 1}) diff --git a/build/graphs/sanitizer.py b/build/graphs/sanitizer.py index c8b1c4e..c3758a6 100644 --- a/build/graphs/sanitizer.py +++ b/build/graphs/sanitizer.py @@ -6,29 +6,23 @@ from dataclasses import dataclass from pathlib import Path -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths -from core.render import TemplateRenderer from core.toolchain import MsvcToolchain from graphs.common import ( BINARIES, BUILD_ROOT, + canonical_artifact, extend_pools, prefixed_identity, - produced_path, python_action, + recipe_factory, require_ancestor, require_positive_integers, require_tool, tool_environment, ) -from graphs.instrumented import ( - InstrumentedArtifact, - InstrumentedVariant, - instrumented_build_slice, - instrumented_dependency_discovery_slice, -) +from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice _SUPPORTED = {("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")} @@ -42,15 +36,6 @@ } -@dataclass(frozen=True, slots=True) -class SanitizerArtifact: - sanitizer: str - architecture: str - name: str - producer: Node - path: Path - - @dataclass(frozen=True, slots=True) class AsanRuntime: architecture: str @@ -58,43 +43,34 @@ class AsanRuntime: identity: Mapping[str, str] -def _artifacts( +def _builds( paths: BuildPaths, upstream: Graph, - artifacts: Iterable[SanitizerArtifact | InstrumentedArtifact], -) -> dict[tuple[str, str], dict[str, SanitizerArtifact | InstrumentedArtifact]]: - selected: dict[tuple[str, str], dict[str, SanitizerArtifact | InstrumentedArtifact]] = {} - for artifact in artifacts: - sanitizer = artifact.sanitizer if isinstance(artifact, SanitizerArtifact) else artifact.kind - key = (sanitizer, artifact.architecture) - if key not in _SUPPORTED or artifact.name not in BINARIES: - raise ValueError(f"invalid sanitizer artifact identity: {(*key, artifact.name)}") - group = selected.setdefault(key, {}) - if artifact.name in group: - raise ValueError(f"duplicate sanitizer artifact: {(*key, artifact.name)}") - expected = f"build-{artifact.name}-{artifact.architecture}-{sanitizer}" - message = f"sanitizer artifact must use its exact producer CAS: {artifact.path}" - if artifact.producer.name != expected: - raise ValueError(message) - binary = produced_path(paths, upstream, artifact.producer, artifact.path, message) - producer_output = paths.cas(artifact.producer.uid, artifact.producer.name).output - if binary != producer_output / BINARIES[artifact.name]: - raise ValueError(f"sanitizer artifact must use its exact producer CAS: {binary}") + selections: tuple[tuple[str, str], ...], +) -> dict[tuple[str, str], dict[str, tuple[Node, Path]]]: + if not selections or len(selections) != len(set(selections)) or any( + item not in _SUPPORTED for item in selections + ): + raise ValueError("sanitizer selections must be a unique non-empty supported set") + selected = {} + for sanitizer, architecture in selections: + group = {} + for name, filename in BINARIES.items(): + producer, path = canonical_artifact( + paths, upstream, f"build-{name}-{architecture}-{sanitizer}", filename + ) + group[name] = (producer, path) restore = ( - f"restore-vcpkg-asan-{artifact.architecture}" + f"restore-vcpkg-asan-{architecture}" if sanitizer == "asan" - else f"restore-vcpkg-{artifact.architecture}" - ) - require_ancestor( - upstream, artifact.producer, restore, - f"sanitizer build requires {restore} as a restore ancestor", + else f"restore-vcpkg-{architecture}" ) - group[artifact.name] = artifact - if not selected: - raise ValueError("sanitizer graph requires at least one complete artifact set") - for key, group in selected.items(): - if group.keys() != BINARIES.keys(): - raise ValueError(f"sanitizer {key} requires the complete artifact set") + for producer, _path in group.values(): + require_ancestor( + upstream, producer, restore, + f"sanitizer build requires {restore} as a restore ancestor", + ) + selected[(sanitizer, architecture)] = group return selected @@ -124,8 +100,8 @@ def _runtimes( def sanitizer_artifact_graph( repository: Path, upstream: Graph, - artifacts: Iterable[SanitizerArtifact | InstrumentedArtifact], *, + selections: tuple[tuple[str, str], ...], pwsh: Path, pwsh_identity: Mapping[str, str], asan_runtimes: Iterable[AsanRuntime] = (), @@ -142,16 +118,15 @@ def sanitizer_artifact_graph( root = repository.resolve(strict=True) paths = BuildPaths(root) pwsh = require_tool(pwsh, "PowerShell") - selected = _artifacts(paths, upstream, artifacts) + selected = _builds(paths, upstream, selections) runtimes = _runtimes(selected, asan_runtimes) - renderer = TemplateRenderer(BUILD_ROOT / "templates") pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) - gate_factory = NodeFactory(renderer, BUILD_ROOT, {}) + gate_factory = recipe_factory(BUILD_ROOT, {}) nodes: list[Node] = [] targets: list[str] = [] for (sanitizer, architecture), group in sorted(selected.items()): - builds = tuple(group[name].producer for name in BINARIES) + builds = tuple(group[name][0] for name in BINARIES) runtime = runtimes.get(architecture) if sanitizer == "asan" else None identity = pwsh_id runtime_copy = None @@ -160,9 +135,9 @@ def sanitizer_artifact_graph( f"asan_runtime.{architecture}", runtime.path, runtime.identity ) runtime_copy = {"name": runtime.path.name, "source": str(runtime.path)} - factory = NodeFactory(renderer, root, identity, environment) + factory = recipe_factory(root, identity, environment) copies = tuple( - {"name": BINARIES[name], "source": str(group[name].path)} + {"name": BINARIES[name], "source": str(group[name][1])} for name in BINARIES ) options_name, options_value = _OPTIONS[sanitizer] @@ -177,13 +152,19 @@ def sanitizer_artifact_graph( "shard_count": test_shards, "shard_index": index, }, files={}, dependencies=builds, + results=(Result( + f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml", + "sanitizer", + "application/xml", + "tests.xml", + ),), config={ "action": "sanitizer-test", "sanitizer": sanitizer, "architecture": architecture, "shard_count": str(test_shards), "shard_index": str(index), }, ) - log = paths.cas(shard.uid, shard.name).log + log = paths.cas(shard.uid).log gate = python_action( gate_factory, f"{sanitizer}-gate-{architecture}-{index}", @@ -260,7 +241,7 @@ def sanitizer_graph( variants = _instrumented_variants( selections, llvm_runtime, llvm_runtime_identity ) - upstream, produced = instrumented_build_slice( + upstream = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -271,7 +252,7 @@ def sanitizer_graph( return sanitizer_artifact_graph( repository, upstream, - produced, + selections=selections, pwsh=toolchain.pwsh, pwsh_identity=dict(toolchain.identity), asan_runtimes=asan_runtimes, diff --git a/build/graphs/source.py b/build/graphs/source.py index a630b85..4ceede9 100644 --- a/build/graphs/source.py +++ b/build/graphs/source.py @@ -5,12 +5,10 @@ from collections.abc import Iterable from pathlib import Path -from core.graph import Graph, Node -from core.node import NodeFactory +from core.graph import Graph, Node, Result from core.paths import BuildPaths -from core.render import TemplateRenderer from core.source_tools import SourceTools -from graphs.common import python_action, restore_node, tool_environment +from graphs.common import python_action, recipe_factory, restore_node, tool_environment _BUILD_ROOT = Path(__file__).resolve().parents[1] @@ -45,15 +43,37 @@ def source_checks(repository: Path, tools: SourceTools, jobs: int = 4, """Return independently cacheable format, analyzer, and contract checks.""" selected_architectures = tuple(architectures) - if (not selected_architectures or len(set(selected_architectures)) != len(selected_architectures) or + if not selected_architectures: + raise ValueError("source architectures must be unique supported architecture names") + return _source_checks(repository, tools, jobs, selected_architectures, include_common=True) + + +def common_source_checks(repository: Path, tools: SourceTools, jobs: int = 4) -> Graph: + """Return only architecture-independent repository checks.""" + + return _source_checks(repository, tools, jobs, (), include_common=True) + + +def architecture_source_checks(repository: Path, tools: SourceTools, + architectures: Iterable[str], jobs: int = 4) -> Graph: + """Return only architecture-parameterized Cppcheck gates.""" + + return _source_checks( + repository, tools, jobs, tuple(architectures), include_common=False + ) + + +def _source_checks(repository: Path, tools: SourceTools, jobs: int, + selected_architectures: tuple[str, ...], *, include_common: bool) -> Graph: + if ((not selected_architectures and not include_common) or + len(set(selected_architectures)) != len(selected_architectures) or any(item not in _CPPCHECK_ARCHITECTURES for item in selected_architectures)): raise ValueError("source architectures must be unique supported architecture names") root = repository.resolve(strict=True) - renderer = TemplateRenderer(_BUILD_ROOT / "templates") identities = dict(tools.identity) restore_identity = {key: identities[key] for key in ("pwsh", "pwsh_version", "vcpkg_root")} - factory = NodeFactory(renderer, root, {}, tools.environment) - restore_factory = NodeFactory(renderer, root, restore_identity, tool_environment(tools)) + factory = recipe_factory(root, {}, tools.environment) + restore_factory = recipe_factory(root, restore_identity, tool_environment(tools)) paths = BuildPaths(root) def tool_identity(*names: str) -> dict[str, str]: @@ -63,9 +83,15 @@ def tool_identity(*names: str) -> dict[str, str]: path for path in (root / "src").rglob("*") if path.is_file() and path.suffix in _CPP_SUFFIXES )) + template_root = root / "build/templates" powershell_sources = (root / "build.ps1",) + tuple(sorted( path for path in (root / "build").rglob("*") - if path.is_file() and path.suffix in _POWERSHELL_SUFFIXES + if ( + path.is_file() + and path.suffix in _POWERSHELL_SUFFIXES + # Raw Jinja is not PowerShell; rendered recipes are parser-tested below this graph. + and not path.is_relative_to(template_root) + ) )) contracts = tuple(sorted((root / "build/tests").glob("*.Tests.ps1"))) @@ -98,7 +124,7 @@ def leaf( config={"action": "format", "source": _relative(root, source)}, ) for source in cpp_sources - ) + ) if include_common else () restore_nodes = [] cppcheck_nodes = [] @@ -107,7 +133,7 @@ def leaf( triplet = f"observer-{architecture}-windows-static" restore = restore_node(root, tools, restore_factory, architecture) restore_nodes.append(restore) - include_dir = paths.cas(restore.uid, restore.name).output / triplet / "include" + include_dir = paths.cas(restore.uid).output / triplet / "include" cppcheck_nodes.append( leaf( "cppcheck.ps1", @@ -118,6 +144,7 @@ def leaf( "repository": str(root), "source_dir": str(root / "src"), "include_dir": str(include_dir), + "triplet": triplet, "platform": platform, "architecture_define": architecture_define, "automation_id": f"cppcheck/{architecture}/", @@ -149,7 +176,7 @@ def leaf( config={"action": "psscriptanalyzer", "source": _relative(root, source)}, ) for source in powershell_sources - ) + ) if include_common else () contract_inputs = _repository_files(root) contract_nodes = tuple( @@ -165,32 +192,43 @@ def leaf( config={"action": "contract", "test": _relative(root, test)}, ) for test in contracts - ) + ) if include_common else () def output(current: Node) -> Path: - return paths.cas(current.uid, current.name).output + return paths.cas(current.uid).output def report(current: Node) -> Path: name = "cppcheck.sarif" if current.name.startswith("cppcheck-") else "psscriptanalyzer.sarif" return output(current) / name finding_nodes = cppcheck_nodes + pssa_nodes + qualifier = "-".join(selected_architectures) + merge_name = "merge-source-findings" if include_common else f"merge-cppcheck-findings-{qualifier}" + gate_name = "source-checks" if include_common else f"cppcheck-checks-{qualifier}" + result_id = ( + "reports/sarif/source/analysis.sarif" + if include_common else f"reports/sarif/{qualifier}/cppcheck.sarif" + ) merged = python_action( factory, - "merge-source-findings", + merge_name, "core.sarif", ("merge", *(str(report(current)) for current in finding_nodes)), finding_nodes, pool="slot", + results=(Result( + result_id, "report", "application/sarif+json", "analysis.sarif" + ),), ) direct = format_nodes + contract_nodes gate = python_action( factory, - "source-checks", + gate_name, "core.sarif", ("gate", str(output(merged) / "analysis.sarif")), (merged,) + direct, pool="slot", ) nodes = restore_nodes + format_nodes + cppcheck_nodes + pssa_nodes + contract_nodes + (merged, gate) - return Graph(nodes, (gate.name,), {"restore": 1, "slot": jobs}) + pools = {"slot": jobs} | ({"restore": 1} if restore_nodes else {}) + return Graph(nodes, (gate.name,), pools) diff --git a/build/main.py b/build/main.py index b433dc6..e79138c 100644 --- a/build/main.py +++ b/build/main.py @@ -52,12 +52,6 @@ def parse(value: str) -> int: return parse -def _threshold(value: str) -> int: - if value != "100": - raise argparse.ArgumentTypeError("coverage threshold is fixed at 100") - return 100 - - _OPTIONS: dict[str, tuple[tuple[str, ...], dict[str, object]]] = { "arch": (("-Arch", "--arch"), {"type": _selection(_ARCHITECTURES), "default": ("x64",)}), "config": (("-Config", "--config"), {"type": _selection(_CONFIGURATIONS), "default": ("Debug",)}), @@ -74,40 +68,23 @@ def _threshold(value: str) -> int: "leak_tolerance": (("-LeakToleranceBytes", "--leak-tolerance-bytes"), { "type": _integer(0, 1_073_741_824), "default": 0, }), - "threshold": (("-CoverageThreshold", "--coverage-threshold"), {"type": _threshold, "default": 100}), + "export_dir": (("-ExportDir", "--export-dir"), {"type": Path}), "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "stale-work"), "default": "all"}), } -_COMMAND_OPTIONS = { - "restore": ("arch", "restore"), "build": ("arch", "config"), - "test": ("arch", "config", "corpus", "shards"), "source-checks": ("arch",), - "compiler-analysis": ("arch",), - "test-coverage": ("arch", "corpus", "shards", "threshold"), - "test-asan": ("arch", "shards"), "test-ubsan": ("arch", "shards"), - "test-leaks": ("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"), - "fuzz": ("arch", "fuzz_seconds", "fuzz_target"), - "audit-binaries": ("arch",), "package": ("arch",), - "verify": ("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", - "leak_iterations", "leak_windows", "leak_tolerance", "threshold"), -} - - def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="observer-build") commands = parser.add_subparsers(dest="command", required=True) - doctor = commands.add_parser("doctor") - doctor.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") - for name, options in _COMMAND_OPTIONS.items(): + commands.add_parser("doctor") + for name, (options, _invoke) in _COMMANDS.items(): command = commands.add_parser(name) command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) command.add_argument("-Jobs", "--jobs", type=_integer(1)) - command.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") for option in options: flags, settings = _OPTIONS[option] command.add_argument(*flags, dest=option, **settings) clean = commands.add_parser("clean") clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) - clean.add_argument("-SkipDependencyRestore", action="store_true", dest="skip_restore") flags, settings = _OPTIONS["clean_mode"] clean.add_argument(*flags, dest="clean_mode", **settings) return parser @@ -136,40 +113,55 @@ async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) return outputs -_INVOKE: dict[str, Invoker] = { - "restore": _restore, - "build": lambda driver, args, _toolchain: driver.build(args.arch, args.config), - "test": lambda driver, args, _toolchain: driver.test( +_COMMANDS: dict[str, tuple[tuple[str, ...], Invoker]] = { + "restore": (("arch", "restore"), _restore), + "build": (("arch", "config"), lambda driver, args, _toolchain: driver.build(args.arch, args.config)), + "test": (("arch", "config", "corpus", "shards"), lambda driver, args, _toolchain: driver.test( args.arch, args.config, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce - ), - "source-checks": lambda driver, args, toolchain: driver.source_checks( + )), + "source-checks": (("arch",), lambda driver, args, toolchain: driver.source_checks( args.arch, discover_source_tools(toolchain) - ), - "compiler-analysis": lambda driver, args, _toolchain: driver.compiler_analysis(args.arch), - "test-coverage": lambda driver, args, _toolchain: driver.test_coverage( + )), + "compiler-analysis": (("arch",), lambda driver, args, _toolchain: driver.compiler_analysis(args.arch)), + "test-coverage": (("arch", "corpus", "shards"), lambda driver, args, _toolchain: driver.test_coverage( args.arch, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce - ), - "test-asan": lambda driver, args, _toolchain: driver.test_asan(args.arch, test_shards=args.shards), - "test-ubsan": lambda driver, args, _toolchain: driver.test_ubsan(args.arch, test_shards=args.shards), - "test-leaks": lambda driver, args, toolchain: driver.test_leaks( + )), + "test-asan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_asan(args.arch, test_shards=args.shards)), + "test-ubsan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_ubsan(args.arch, test_shards=args.shards)), + "test-leaks": (("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"), lambda driver, args, toolchain: driver.test_leaks( run_nonce=args.run_nonce, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(), umdh=resolve_umdh(), warmup=args.leak_warmup, iterations=args.leak_iterations, windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, - ), - "fuzz": lambda driver, args, _toolchain: driver.fuzz( + )), + "fuzz": (("arch", "fuzz_seconds", "fuzz_target"), lambda driver, args, _toolchain: driver.fuzz( run_nonce=args.run_nonce, seconds=args.fuzz_seconds, targets=args.fuzz_target - ), - "audit-binaries": lambda driver, args, toolchain: driver.audit( + )), + "audit-binaries": (("arch",), lambda driver, args, toolchain: driver.audit( args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim() - ), - "package": lambda driver, args, toolchain: driver.package( - args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim() - ), - "verify": lambda driver, args, _toolchain: driver.verify( + )), + "package": (("arch", "export_dir"), lambda driver, args, toolchain: driver.package( + args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(), + export_dir=args.export_dir, + )), + "verify-source": (("export_dir",), lambda driver, args, _toolchain: driver.verify_source( + export_dir=args.export_dir, + )), + "verify-arch": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir"), + lambda driver, args, _toolchain: driver.verify_arch( + args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, + test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + export_dir=args.export_dir, + )), + "verify": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir"), + lambda driver, args, _toolchain: driver.verify( args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, - ), + export_dir=args.export_dir, + )), } @@ -180,19 +172,19 @@ def main(argv: Sequence[str] | None = None) -> int: parser.print_help() return 0 args = parser.parse_args(arguments) - if args.command == "restore" and args.skip_restore: - parser.error("-SkipDependencyRestore is invalid for restore") if args.command == "doctor": return doctor_main(()) if args.command == "clean": return run_clean(args.repository, args.clean_mode) if args.command in {"fuzz", "test-leaks"} and args.arch != ("x64",): parser.error(f"{args.command} requires -Arch x64") + if args.command == "verify-arch" and len(args.arch) != 1: + parser.error("verify-arch requires exactly one architecture") args.run_nonce = _run_id() toolchain = discover_msvc_toolchain() driver = BuildDriver(args.repository, args.run_nonce, toolchain, jobs=args.jobs) - outputs = asyncio.run(_INVOKE[args.command](driver, args, toolchain)) - if args.command == "verify": + outputs = asyncio.run(_COMMANDS[args.command][1](driver, args, toolchain)) + if args.command in {"verify", "verify-arch"}: for item in verify_route(args.arch).deferred: print(f"[DEFERRED] {item.gate} {item.architecture}: {item.reason}") for output in outputs: diff --git a/build/templates/_output.ps1 b/build/templates/_output.ps1 new file mode 100644 index 0000000..83a273f --- /dev/null +++ b/build/templates/_output.ps1 @@ -0,0 +1,5 @@ +{% macro require_output(relative_path, message, path_type='Leaf') -%} +if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ relative_path | ps_quote }}) -PathType {{ path_type }})) { + throw {{ message | ps_quote }} +} +{%- endmacro %} diff --git a/build/templates/base.json b/build/templates/base.json index 7c0fe5c..5855f42 100644 --- a/build/templates/base.json +++ b/build/templates/base.json @@ -2,5 +2,6 @@ "name": {{ name | json }}, "pool": {{ pool | json }}, "inputs": {{ inputs | json }}, + "results": {{ results | json }}, "script": {% block script %}null{% endblock %} } diff --git a/build/templates/catch2-test.ps1 b/build/templates/catch2-test.ps1 index 56fa743..654f675 100644 --- a/build/templates/catch2-test.ps1 +++ b/build/templates/catch2-test.ps1 @@ -1,4 +1,4 @@ -{% extends "pwsh.ps1" %} +{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %} {% block pwsh_body %} {% for artifact in artifacts %}Copy-Item -LiteralPath {{ artifact.source | ps_quote }} -Destination (Join-Path $outDir {{ artifact.name | ps_quote }}) {% endfor %} @@ -22,7 +22,5 @@ try { } finally { Pop-Location } -{% block catch2_post %}if (-not (Test-Path -LiteralPath (Join-Path $outDir 'tests.xml') -PathType Leaf)) { - throw 'Catch2 did not produce tests.xml' -} +{% block catch2_post %}{{ require_output('tests.xml', 'Catch2 did not produce tests.xml') }} {% endblock %}{% endblock %} diff --git a/build/templates/clang-command.ps1 b/build/templates/clang-command.ps1 index 0a854f7..e62748c 100644 --- a/build/templates/clang-command.ps1 +++ b/build/templates/clang-command.ps1 @@ -1,10 +1,8 @@ -{% extends "selected-compile.ps1" %} +{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %} {% block compile_args %} "/p:ObserverClangCommandPath=$outDir\compile-command.json" {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} {% endblock %} {% block post_msbuild %} -if (-not (Test-Path -LiteralPath (Join-Path $outDir 'compile-command.json') -PathType Leaf)) { - throw 'clang-cl did not produce compile-command.json' -} +{{ require_output('compile-command.json', 'clang-cl did not produce compile-command.json') }} {% endblock %} diff --git a/build/templates/clang-tidy.ps1 b/build/templates/clang-tidy.ps1 index 1594fd8..4db324c 100644 --- a/build/templates/clang-tidy.ps1 +++ b/build/templates/clang-tidy.ps1 @@ -1,4 +1,4 @@ -{% extends "analysis.ps1" %} +{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %} {% block int_dir %} "/p:IntDir=$outDir\obj\" {% endblock %} @@ -9,7 +9,5 @@ {{ ('/p:ClangTidyLogFile=' ~ project_name ~ '.ClangTidy.log') | ps_quote }} {% endblock %} {% block post_msbuild %} -if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ ('obj\\' ~ project_name ~ '.ClangTidy.log') | ps_quote }}) -PathType Leaf)) { - throw {{ ('clang-tidy did not produce ' ~ project_name ~ '.ClangTidy.log') | ps_quote }} -} +{{ require_output('obj\\' ~ project_name ~ '.ClangTidy.log', 'clang-tidy did not produce ' ~ project_name ~ '.ClangTidy.log') }} {% endblock %} diff --git a/build/templates/cppcheck.ps1 b/build/templates/cppcheck.ps1 index 3b58cf9..e015bbe 100644 --- a/build/templates/cppcheck.ps1 +++ b/build/templates/cppcheck.ps1 @@ -20,7 +20,7 @@ Invoke-Checked {{ cppcheck | ps_quote }} @( '--inline-suppr' '--suppress=missingIncludeSystem' '--suppress=uninitMemberVarNoCtor:src/api.h' - '--suppress=*:out/cas/*-restore-vcpkg-*/out/*' + {{ ('--suppress=*:*\\' ~ triplet ~ '\\include\\*') | ps_quote }} '--suppress=functionStatic' {{ ('--relative-paths=' ~ repository) | ps_quote }} '--output-format=sarif' diff --git a/build/templates/fuzz-build.ps1 b/build/templates/fuzz-build.ps1 index 55b8699..3fcbeb6 100644 --- a/build/templates/fuzz-build.ps1 +++ b/build/templates/fuzz-build.ps1 @@ -1,11 +1,9 @@ -{% extends "msbuild.ps1" %} +{% extends "msbuild.ps1" %}{% from "_output.ps1" import require_output %} {% block msbuild_args %} {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }} {% endblock %} -{% block post_msbuild %}if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ executable_name | ps_quote }}) -PathType Leaf)) { - throw 'MSBuild did not produce the fuzzer executable' -} +{% block post_msbuild %}{{ require_output(executable_name, 'MSBuild did not produce the fuzzer executable') }} {% endblock %} diff --git a/build/templates/msvc-analyze.ps1 b/build/templates/msvc-analyze.ps1 index b44c2cc..92f3d0f 100644 --- a/build/templates/msvc-analyze.ps1 +++ b/build/templates/msvc-analyze.ps1 @@ -1,4 +1,4 @@ -{% extends "analysis.ps1" %} +{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %} {% block analyzer_args %} '/p:EnableMicrosoftCodeAnalysis=true' '/p:ObserverEnableClangTidy=false' @@ -6,7 +6,5 @@ "/p:ObserverAnalysisReportPath=$outDir\{{ project_name }}.sarif" {% endblock %} {% block post_msbuild %} -if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ (project_name ~ '.sarif') | ps_quote }}) -PathType Leaf)) { - throw {{ ('MSVC analysis did not produce ' ~ project_name ~ '.sarif') | ps_quote }} -} +{{ require_output(project_name ~ '.sarif', 'MSVC analysis did not produce ' ~ project_name ~ '.sarif') }} {% endblock %} diff --git a/build/templates/sanitizer-test.ps1 b/build/templates/sanitizer-test.ps1 index 3af117c..0dc2264 100644 --- a/build/templates/sanitizer-test.ps1 +++ b/build/templates/sanitizer-test.ps1 @@ -1,8 +1,6 @@ -{% extends "catch2-test.ps1" %} +{% extends "catch2-test.ps1" %}{% from "_output.ps1" import require_output %} {% block catch2_setup %}{% if runtime %}Copy-Item -LiteralPath {{ runtime.source | ps_quote }} -Destination (Join-Path $outDir {{ runtime.name | ps_quote }}) {% endif %}$env:{{ options_name }} = {{ options_value | ps_quote }} {% endblock %} -{% block catch2_post %}if (-not (Test-Path -LiteralPath (Join-Path $outDir 'tests.xml') -PathType Leaf)) { - throw 'Sanitizer Catch2 shard did not produce tests.xml' -} +{% block catch2_post %}{{ require_output('tests.xml', 'Sanitizer Catch2 shard did not produce tests.xml') }} {% endblock %} diff --git a/build/templates/source-dependencies.ps1 b/build/templates/source-dependencies.ps1 index 756601f..5f4f01c 100644 --- a/build/templates/source-dependencies.ps1 +++ b/build/templates/source-dependencies.ps1 @@ -1,9 +1,7 @@ -{% extends "selected-compile.ps1" %} +{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %} {% block compile_args %} "/p:ObserverSourceDependenciesPath=$outDir\dependencies.json" {% endblock %} {% block post_msbuild %} -if (-not (Test-Path -LiteralPath (Join-Path $outDir 'dependencies.json') -PathType Leaf)) { - throw 'MSVC did not produce dependencies.json' -} +{{ require_output('dependencies.json', 'MSVC did not produce dependencies.json') }} {% endblock %} diff --git a/build/templates/vcpkg.ps1 b/build/templates/vcpkg.ps1 index c449f69..82a606c 100644 --- a/build/templates/vcpkg.ps1 +++ b/build/templates/vcpkg.ps1 @@ -1,17 +1,14 @@ -{% extends "pwsh.ps1" %} +{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %} {% block pwsh_body %} Invoke-Checked {{ vcpkg | ps_quote }} @( 'install' "--x-install-root=$outDir" "--x-buildtrees-root=$buildDir\b" "--x-packages-root=$buildDir\p" - "--downloads-root=$buildDir\d" '--triplet' {{ triplet | ps_quote }} {{ ('--x-manifest-root=' ~ repository) | ps_quote }} {{ ('--overlay-triplets=' ~ repository ~ '\\build\\vcpkg\\triplets') | ps_quote }} ) -if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ (triplet ~ '\\include') | ps_quote }}) -PathType Container)) { - throw 'vcpkg restore did not produce the include directory' -} +{{ require_output(triplet ~ '\\include', 'vcpkg restore did not produce the include directory', 'Container') }} {% endblock %} diff --git a/build/tests/test_analysis_graph.py b/build/tests/test_analysis_graph.py index afcdf2d..17b23bf 100644 --- a/build/tests/test_analysis_graph.py +++ b/build/tests/test_analysis_graph.py @@ -13,7 +13,8 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.paths import BuildPaths # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 +import graphs.analysis as analysis # noqa: E402 from graphs.analysis import ( # noqa: E402 _manifest_index, analysis_discovery_slice, @@ -106,6 +107,34 @@ def manifest(source: Path, *includes: Path) -> bytes: separators=(",", ":"), ).encode() + def test_project_inventory_parses_each_project_once_and_preserves_requested_order( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = self.repository(Path(temporary) / "repo") + with mock.patch.object( + analysis.ET, "parse", wraps=analysis.ET.parse + ) as parse: + projects = analysis.project_inventory( + repository, ("rpgmaker", "renpy") + ) + + self.assertEqual(parse.call_count, 2) + self.assertEqual(tuple(project.name for project in projects), ("rpgmaker", "renpy")) + self.assertEqual( + projects[0].inputs, + ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/projects/rpgmaker.vcxproj", + ), + ) + self.assertEqual( + projects[0].sources, + (repository / "src/modules/rpgmaker/rpgmaker.cpp",), + ) + def staged_graphs( self, repository: Path, toolchain: FakeToolchain, *, jobs: int = 2, architectures: tuple[str, ...] = ("x64",), @@ -129,7 +158,7 @@ def staged_graphs( if "-rpgmaker-" in target: architecture = target.removeprefix("discover-dependencies-").split("-", 1)[0] restore = discovery.node(f"restore-vcpkg-{architecture}") - package = paths.cas(restore.uid, restore.name).output / "include/zlib.h" + package = paths.cas(restore.uid).output / "include/zlib.h" package.parent.mkdir(parents=True, exist_ok=True) if not package.exists(): package.write_text("#pragma once\n", encoding="utf-8") @@ -155,6 +184,25 @@ def test_two_analysis_backends_are_independent_demand_targets(self) -> None: graph.targets, ("analysis-x64",), ) + merged = graph.node("merge-analysis-x64") + self.assertEqual( + tuple( + (result.id, result.kind, result.media_type, result.relative_path) + for result in merged.results + ), + (( + "reports/sarif/x64/analysis.sarif", + "sarif", + "application/sarif+json", + "analysis.sarif", + ),), + ) + self.assertEqual(graph.node("analysis-x64").results, ()) + self.assertTrue(all( + not node.results + for node in graph.nodes + if node.name.startswith(("restore-", "discover-", "analyze-", "normalize-")) + )) self.assertEqual(graph.pools, {"misc": 2, "restore": 1, "slot": 2}) self.assertEqual( tuple(node.name for node in graph.nodes), @@ -303,15 +351,16 @@ def test_phase_two_signs_only_compiler_reported_project_and_package_files(self) unrelated = repository / "src/unused.h" unrelated.write_text("one\n", encoding="utf-8") toolchain = self.toolchain(root) - _discovery, baseline = self.staged_graphs(repository, toolchain) + discovery, baseline = self.staged_graphs(repository, toolchain) unrelated.write_text("two\n", encoding="utf-8") _discovery, unrelated_changed = self.staged_graphs(repository, toolchain) (repository / "src/modules/renpy/pickle.h").write_text( "#pragma once\n// changed\n", encoding="utf-8" ) _discovery, header_changed = self.staged_graphs(repository, toolchain) - package = next((repository / "out/cas").glob("*-restore-vcpkg-x64")) - (package / "out/include/zlib.h").write_text("// changed\n", encoding="utf-8") + restore = discovery.node("restore-vcpkg-x64") + package = BuildPaths(repository).cas(restore.uid).output + (package / "include/zlib.h").write_text("// changed\n", encoding="utf-8") _discovery, package_changed = self.staged_graphs(repository, toolchain) names = { @@ -342,7 +391,7 @@ def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: expected = {} for target in discovery.targets: node = discovery.node(target) - cas = paths.cas(node.uid, node.name) + cas = paths.cas(node.uid) cas.output.mkdir(parents=True) source = repository / ( "src/modules/renpy/pickle.cpp" @@ -358,28 +407,64 @@ def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: self.assertEqual(loaded, expected) - def test_manifest_index_keeps_newest_candidate_regardless_of_enumeration_order(self) -> None: + def test_manifest_index_loads_only_the_exact_base_uid_without_scanning(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" repository.mkdir() paths = BuildPaths(repository) paths.prepare() name = "discover-dependencies-x64-renpy-modules.renpy.pickle" - older = paths.cas("1" * 32, name) - newer = paths.cas("2" * 32, name) + exact = paths.cas("1" * 32) + decoy = paths.cas("2" * 32) for cas, content, timestamp in ( - (older, b"older", 100), - (newer, b"newer", 200), + (exact, b"exact", 100), + (decoy, b"newer decoy", 200), ): cas.output.mkdir(parents=True) (cas.output / "dependencies.json").write_bytes(content) cas.touch.touch() os.utime(cas.touch, ns=(timestamp, timestamp)) - with mock.patch.object(Path, "glob", return_value=(newer.entry, older.entry)): - loaded = _manifest_index(repository, {name}) + with mock.patch.object( + Path, "glob", side_effect=AssertionError("CAS must not be scanned") + ): + loaded = _manifest_index(repository, {name: "1" * 32}) + + self.assertEqual(loaded, {name: b"exact"}) - self.assertEqual(loaded, {name: b"newer"}) + def test_manifest_index_ignores_missing_and_incomplete_exact_entries(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + paths.prepare() + incomplete = paths.cas("1" * 32) + incomplete.output.mkdir(parents=True) + (incomplete.output / "dependencies.json").write_bytes(b"partial") + + loaded = _manifest_index( + repository, + {"missing": "2" * 32, "incomplete": "1" * 32}, + ) + + self.assertEqual(loaded, {}) + + def test_manifest_index_rejects_reparse_manifest_leaf(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + paths.prepare() + cas = paths.cas("1" * 32) + cas.output.mkdir(parents=True) + manifest = cas.output / "dependencies.json" + manifest.write_bytes(b"manifest") + cas.touch.touch() + + with mock.patch( + "core.paths._is_reparse", side_effect=lambda path: Path(path) == manifest + ), self.assertRaisesRegex(PathSafetyError, "reparse point"): + _manifest_index(repository, {"node": "1" * 32}) def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -390,14 +475,14 @@ def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"): load_dependency_manifests(repository, discovery) - def test_prior_manifest_dependencies_seed_only_their_discovery_uid(self) -> None: + def test_base_manifest_seeds_header_only_invalidation_without_directory_scan(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = self.repository(root / "repo") toolchain = self.toolchain(root) initial = analysis_discovery_slice(repository, toolchain) renpy = initial.node(initial.targets[0]) - cas = BuildPaths(repository).cas(renpy.uid, renpy.name) + cas = BuildPaths(repository).cas(renpy.uid) cas.output.mkdir(parents=True) (cas.output / "dependencies.json").write_bytes( self.manifest( @@ -406,18 +491,32 @@ def test_prior_manifest_dependencies_seed_only_their_discovery_uid(self) -> None ) ) cas.touch.touch() - other = BuildPaths(repository).cas("1" * 32, renpy.name) + other = BuildPaths(repository).cas("1" * 32) other.output.mkdir(parents=True) (other.output / "dependencies.json").write_bytes( (cas.output / "dependencies.json").read_bytes() ) other.touch.touch() - BuildPaths(repository).cas("2" * 32, renpy.name).entry.mkdir(parents=True) - before = analysis_discovery_slice(repository, toolchain) + BuildPaths(repository).cas("2" * 32).entry.mkdir(parents=True) + + original_glob = Path.glob + + def reject_cas_scan(path: Path, pattern: str, **kwargs: object): + if path == BuildPaths(repository).cas_root: + raise AssertionError("CAS must not be scanned") + return original_glob(path, pattern, **kwargs) + + with mock.patch.object( + Path, "glob", autospec=True, side_effect=reject_cas_scan + ): + before = analysis_discovery_slice(repository, toolchain) (repository / "src/modules/renpy/pickle.h").write_text( "#pragma once\n// topology may have changed\n", encoding="utf-8" ) - after = analysis_discovery_slice(repository, toolchain) + with mock.patch.object( + Path, "glob", autospec=True, side_effect=reject_cas_scan + ): + after = analysis_discovery_slice(repository, toolchain) self.assertNotEqual(before.node(before.targets[0]).uid, after.node(after.targets[0]).uid) self.assertEqual(before.node(before.targets[1]).uid, after.node(after.targets[1]).uid) diff --git a/build/tests/test_audit_graph.py b/build/tests/test_audit_graph.py index 632a415..f54207c 100644 --- a/build/tests/test_audit_graph.py +++ b/build/tests/test_audit_graph.py @@ -16,7 +16,7 @@ from core.binary_audit import AuditError, main as binary_audit_main # noqa: E402 from core.graph import Command, Graph, Node # noqa: E402 from core.paths import BuildPaths # noqa: E402 -from graphs.audit import BinaryArtifact, audit_graph # noqa: E402 +from graphs.audit import audit_graph # noqa: E402 def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: @@ -30,31 +30,26 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: class AuditGraphTests(unittest.TestCase): - def fixture(self, root: Path) -> tuple[Path, Graph, tuple[BinaryArtifact, ...], Path, Path]: + def fixture(self, root: Path) -> tuple[Path, Graph, Path, Path]: repository = root / "repo" repository.mkdir() restore = node("restore-release") - renpy = node("build-renpy-x64-release", inputs=(restore.name,)) - rpgmaker = node("build-rpgmaker-x86-release", inputs=(restore.name,)) + builds = tuple( + node(f"build-{module}-{architecture}-release", inputs=(restore.name,)) + for architecture in ("x64", "x86") + for module in ("renpy", "rpgmaker", "zanzarah") + ) upstream = Graph( - (restore, renpy, rpgmaker), - (renpy.name, rpgmaker.name), + (restore, *builds), + tuple(item.name for item in builds), {"build": 2}, ) - paths = BuildPaths(repository) - artifacts = ( - BinaryArtifact("x64", "renpy", renpy, paths.cas(renpy.uid, renpy.name).output / "renpy.so"), - BinaryArtifact( - "x86", "rpgmaker", rpgmaker, - paths.cas(rpgmaker.uid, rpgmaker.name).output / "rpgmaker.so", - ), - ) tools = root / "tools" tools.mkdir() dumpbin, binskim = tools / "dumpbin.exe", tools / "BinSkim.exe" dumpbin.touch() binskim.touch() - return repository, upstream, artifacts, dumpbin, binskim + return repository, upstream, dumpbin, binskim def build_graph( self, @@ -63,17 +58,38 @@ def build_graph( dumpbin_version: str = "14.44", binskim_version: str = "4.4", ) -> Graph: - repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + repository, upstream, dumpbin, binskim = self.fixture(root) return audit_graph( repository, upstream, - artifacts, + architectures=("x64", "x86"), dumpbin=dumpbin, dumpbin_identity={"path": str(dumpbin), "version": dumpbin_version}, binskim=binskim, binskim_identity={"path": str(binskim), "version": binskim_version}, jobs=3, - binskim_jobs=2, + ) + + def test_selects_canonical_release_producers_without_artifact_dtos(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream, dumpbin, binskim = self.fixture(Path(temporary)) + graph = audit_graph( + repository, + upstream, + architectures=("x64", "x86"), + dumpbin=dumpbin, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + ) + + self.assertEqual( + graph.node("audit-binskim-run-x64-renpy").inputs, + ("build-renpy-x64-release",), + ) + self.assertEqual( + graph.node("audit-binskim-run-x86-rpgmaker").inputs, + ("build-rpgmaker-x86-release",), ) def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(self) -> None: @@ -81,23 +97,22 @@ def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(se root = Path(temporary) graph = self.build_graph(root) - self.assertEqual(graph.pools, {"build": 2, "audit": 3, "binskim": 2, "dumpbin": 3}) + self.assertEqual(graph.pools, {"build": 2, "slot": 3}) self.assertEqual( graph.targets, - ( - "audit-pe-x64-renpy", - "audit-binskim-x64-renpy", - "audit-pe-x86-rpgmaker", - "audit-binskim-x86-rpgmaker", + tuple( + f"audit-{kind}-{architecture}-{module}" + for architecture in ("x64", "x86") + for module in ("renpy", "rpgmaker", "zanzarah") + for kind in ("pe", "binskim") ), ) - self.assertEqual(15, len(graph.nodes)) + self.assertEqual(43, len(graph.nodes)) self.assertEqual(graph.node("build-renpy-x64-release").inputs, ("restore-release",)) - for architecture, module, producer in ( - ("x64", "renpy", "build-renpy-x64-release"), - ("x86", "rpgmaker", "build-rpgmaker-x86-release"), - ): + for architecture in ("x64", "x86"): + for module in ("renpy", "rpgmaker", "zanzarah"): + producer = f"build-{module}-{architecture}-release" dump_nodes = tuple( graph.node(f"audit-dumpbin-{mode}-{architecture}-{module}") for mode in ("headers", "dependents", "exports") @@ -109,55 +124,65 @@ def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(se self.assertEqual(pe_gate.inputs, tuple(current.name for current in dump_nodes)) self.assertEqual(binskim_run.inputs, (producer,)) self.assertEqual(binskim_gate.inputs, (binskim_run.name,)) - self.assertTrue(all(current.pool == "dumpbin" for current in dump_nodes)) - self.assertEqual((pe_gate.pool, binskim_run.pool, binskim_gate.pool), ("audit", "binskim", "audit")) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in binskim_run.results), + (( + f"reports/sarif/{architecture}/binskim-{module}.sarif", + "sarif", "application/sarif+json", "binskim.sarif", + ),), + ) + self.assertTrue(all(not current.results for current in (*dump_nodes, pe_gate, binskim_gate))) + self.assertTrue(all(current.pool == "slot" for current in dump_nodes)) + self.assertEqual((pe_gate.pool, binskim_run.pool, binskim_gate.pool), ("slot", "slot", "slot")) def test_dumpbin_and_python_gates_receive_exact_binary_logs_and_report_paths(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + repository, upstream, dumpbin, binskim = self.fixture(root) graph = audit_graph( repository, upstream, - artifacts[:1], + architectures=("x64",), dumpbin=dumpbin, dumpbin_identity={"version": "14.44"}, binskim=binskim, binskim_identity={"version": "4.4"}, ) paths = BuildPaths(repository) + binary = paths.cas(upstream.node("build-renpy-x64-release").uid).output / "renpy.so" dump_nodes = tuple( graph.node(f"audit-dumpbin-{mode}-x64-renpy") for mode in ("headers", "dependents", "exports") ) for mode, current in zip(("headers", "dependents", "exports"), dump_nodes, strict=True): - self.assertEqual(current.command.argv, (str(dumpbin), f"/{mode}", str(artifacts[0].path))) + self.assertEqual(current.command.argv, (str(dumpbin), f"/{mode}", str(binary))) pe_gate = graph.node("audit-pe-x64-renpy") self.assertEqual(pe_gate.command.argv[1:5], ("-m", "core.binary_audit", "pe", "x64")) self.assertEqual( pe_gate.command.argv[5:], - tuple(str(paths.cas(current.uid, current.name).log) for current in dump_nodes), + tuple(str(paths.cas(current.uid).log) for current in dump_nodes), ) binskim_run = graph.node("audit-binskim-run-x64-renpy") self.assertEqual( binskim_run.command.argv[1:], - ("-m", "core.binary_audit", "run-binskim", str(binskim), str(artifacts[0].path)), + ("-m", "core.binary_audit", "run-binskim", str(binskim), str(binary)), ) binskim_gate = graph.node("audit-binskim-x64-renpy") - report = paths.cas(binskim_run.uid, binskim_run.name).output / "binskim.sarif" + report = paths.cas(binskim_run.uid).output / "binskim.sarif" self.assertEqual(binskim_gate.command.argv[1:], ("-m", "core.binary_audit", "binskim", str(report))) def test_tool_identity_invalidates_only_its_run_and_semantic_consumers(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + repository, upstream, dumpbin, binskim = self.fixture(root) def graph(dumpbin_version: str, binskim_version: str) -> Graph: return audit_graph( repository, upstream, - artifacts, + architectures=("x64", "x86"), dumpbin=dumpbin, dumpbin_identity={"version": dumpbin_version}, binskim=binskim, @@ -182,86 +207,56 @@ def graph(dumpbin_version: str, binskim_version: str) -> Graph: current.name, ) - def test_artifact_must_match_its_exact_upstream_producer_cas(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) - escaped = BinaryArtifact("x64", "renpy", artifacts[0].producer, root / "outside.so") - with self.assertRaisesRegex(ValueError, "producer CAS"): - audit_graph( - repository, - upstream, - (escaped,), - dumpbin=dumpbin, - dumpbin_identity={"version": "14.44"}, - binskim=binskim, - binskim_identity={"version": "4.4"}, - ) - - def test_graph_rejects_ambiguous_tools_pools_artifacts_and_capacities(self) -> None: + def test_graph_rejects_invalid_axes_tools_pools_and_capacities(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, dumpbin, binskim = self.fixture(root) + repository, upstream, dumpbin, binskim = self.fixture(root) def invoke( - selected: tuple[BinaryArtifact, ...] = artifacts, *, source: Graph = upstream, + architectures: tuple[str, ...] = ("x64", "x86"), dumpbin_path: Path = dumpbin, jobs: object = 2, - binskim_jobs: object = 1, ) -> Graph: return audit_graph( repository, source, - selected, + architectures=architectures, dumpbin=dumpbin_path, dumpbin_identity={"version": "14.44"}, binskim=binskim, binskim_identity={"version": "4.4"}, jobs=jobs, # type: ignore[arg-type] - binskim_jobs=binskim_jobs, # type: ignore[arg-type] ) - for jobs, binskim_jobs in ((True, 1), ("two", 1), (2, 0)): - with self.subTest(capacities=(jobs, binskim_jobs)), self.assertRaisesRegex( + for jobs in (True, "two", 0): + with self.subTest(jobs=jobs), self.assertRaisesRegex( ValueError, "capacities" ): - invoke(jobs=jobs, binskim_jobs=binskim_jobs) + invoke(jobs=jobs) with self.assertRaisesRegex(FileNotFoundError, "not a file"): invoke(dumpbin_path=dumpbin.parent) - with self.assertRaisesRegex(ValueError, "at least one"): - invoke(()) - with self.assertRaisesRegex(ValueError, "duplicate"): - invoke((artifacts[0], artifacts[0])) - for invalid in ( - BinaryArtifact("riscv64", "renpy", artifacts[0].producer, artifacts[0].path), - BinaryArtifact("x64", "Bad Module", artifacts[0].producer, artifacts[0].path), - ): - with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "identity"): - invoke((invalid,)) - - impostor = node(artifacts[0].producer.name) - mismatched = BinaryArtifact("x64", "renpy", impostor, artifacts[0].path) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((mismatched,)) - expected_output = BuildPaths(repository).cas( - artifacts[0].producer.uid, artifacts[0].producer.name - ).output - for wrong_path in (expected_output, BuildPaths(repository).cas_root / "other.so"): - with self.subTest(wrong_path=wrong_path), self.assertRaisesRegex( - ValueError, "producer CAS" - ): - invoke((BinaryArtifact("x64", "renpy", artifacts[0].producer, wrong_path),)) + for invalid in ((), ("x64", "x64"), ("riscv64",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + invoke(architectures=invalid) + + incomplete = Graph( + tuple(item for item in upstream.nodes if item.name != "build-renpy-x64-release"), + tuple(name for name in upstream.targets if name != "build-renpy-x64-release"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=incomplete) matching_pools = Graph( upstream.nodes, upstream.targets, - {"build": 2, "audit": 2, "binskim": 1, "dumpbin": 2}, + {"build": 2, "slot": 2}, ) - self.assertEqual(invoke(source=matching_pools).pools["audit"], 2) - conflicting = Graph(upstream.nodes, upstream.targets, {"build": 2, "audit": 1}) + self.assertEqual(invoke(source=matching_pools).pools["slot"], 2) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 2, "slot": 1}) with self.assertRaisesRegex(ValueError, "conflicting pool"): invoke(source=conflicting) diff --git a/build/tests/test_clean.py b/build/tests/test_clean.py index 4b0a80d..3dbf652 100644 --- a/build/tests/test_clean.py +++ b/build/tests/test_clean.py @@ -36,8 +36,12 @@ def test_all_removes_only_exact_generated_output(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository, paths = self.repository(Path(temporary)) paths.prepare() - (paths.cas_root / "entry").mkdir() - (paths.cas_root / "entry/data").write_text("generated", encoding="utf-8") + current = paths.cas(UID).entry + current.mkdir() + (current / "data").write_text("generated", encoding="utf-8") + legacy = paths.cas_root / f"{UID}-legacy-node" + legacy.mkdir() + (legacy / "data").write_text("old generated", encoding="utf-8") run = paths.run_work("old-run") run.mkdir() inactive_lock = paths.lock(UID) @@ -64,9 +68,12 @@ def test_stale_work_removes_only_inactive_exact_run_directories(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository, paths = self.repository(Path(temporary)) paths.prepare() - cached = paths.cas_root / "entry/out" + cached = paths.cas(UID).output cached.mkdir(parents=True) (cached / "module.so").touch() + legacy = paths.cas_root / f"{UID}-legacy-node" / "out" + legacy.mkdir(parents=True) + (legacy / "module.so").touch() runs = tuple(paths.run_work(name) for name in ("run-b", "run-a")) for run in runs: run.mkdir() @@ -80,6 +87,7 @@ def test_stale_work_removes_only_inactive_exact_run_directories(self) -> None: self.assertEqual(removed, tuple(sorted(runs))) self.assertTrue((cached / "module.so").is_file()) + self.assertTrue((legacy / "module.so").is_file()) self.assertTrue(paths.locks_root.is_dir()) self.assertTrue(paths.lock(UID).is_file()) self.assertTrue(all(not run.exists() for run in runs)) @@ -147,9 +155,10 @@ def test_unsafe_layout_types_and_reparse_points_are_rejected(self) -> None: reparse_repository, reparse = self.repository(root / "reparse") reparse.prepare() - (reparse.cas_root / "entry").mkdir() + reparse_entry = reparse.cas(UID).entry + reparse_entry.mkdir() with mock.patch( - "core.paths._is_reparse", side_effect=lambda path: Path(path) == reparse.cas_root / "entry" + "core.paths._is_reparse", side_effect=lambda path: Path(path) == reparse_entry ), self.assertRaisesRegex(PathSafetyError, "reparse point"): clean(reparse_repository) @@ -169,7 +178,7 @@ def resolve(path: Path, *, strict: bool = False) -> Path: special_repository, special = self.repository(root / "special") special.prepare() - special_entry = special.cas_root / "special" + special_entry = special.cas(UID).entry special_entry.touch() original_lstat = os.lstat diff --git a/build/tests/test_common.py b/build/tests/test_common.py index 34f37f6..69cb977 100644 --- a/build/tests/test_common.py +++ b/build/tests/test_common.py @@ -5,10 +5,10 @@ import tempfile import unittest -from core.graph import Graph +from core.graph import Graph, Result from core.node import NodeFactory from core.render import TemplateRenderer -from graphs.common import BUILD_ROOT, restore_node +from graphs.common import BUILD_ROOT, recipe_factory, restore_node @dataclass(frozen=True) @@ -29,6 +29,28 @@ def repository(root: Path) -> Path: return root +class RecipeFactoryTests(unittest.TestCase): + def test_matches_direct_node_factory_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + cwd = Path(temporary) / "working-directory" + identity = {"compiler": "exact-version", "tool": "exact-path"} + environment = (("ZED", "last"), ("ALPHA", "first")) + result = Result("reports/sample.json", "report", "application/json", "sample.json") + + def make(factory: NodeFactory): + return factory.make( + "argv.json", "sample", "slot", {"argv": ("tool.exe", "--probe")}, + files={}, results=(result,), config={"action": "probe"}, + ) + + expected = make(NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment + )) + actual = make(recipe_factory(cwd, identity, environment)) + + self.assertEqual(actual, expected) + + class RestoreNodeTests(unittest.TestCase): def fixture(self, root: Path) -> tuple[Path, Toolchain]: repo = repository(root / "repo") diff --git a/build/tests/test_cpp_coverage_graph.py b/build/tests/test_cpp_coverage_graph.py index 6f63d83..287039a 100644 --- a/build/tests/test_cpp_coverage_graph.py +++ b/build/tests/test_cpp_coverage_graph.py @@ -19,7 +19,6 @@ from core.graph import Command, Graph, Node # noqa: E402 from core.paths import BuildPaths # noqa: E402 from graphs.coverage import ( # noqa: E402 - CoverageArtifact, coverage_artifact_graph, coverage_dependency_discovery_slice, coverage_graph, @@ -40,12 +39,10 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: class CppCoverageGraphTests(unittest.TestCase): def fixture( self, root: Path, architectures: tuple[str, ...] = ("x64", "x86") - ) -> tuple[Path, Graph, tuple[CoverageArtifact, ...], dict[str, Path]]: + ) -> tuple[Path, Graph, dict[str, Path]]: repository = root / "repo" repository.mkdir() - paths = BuildPaths(repository) nodes = [] - artifacts = [] for architecture in architectures: restore = node(f"restore-vcpkg-{architecture}") discovery = node(f"coverage-dependencies-{architecture}", inputs=(restore.name,)) @@ -60,14 +57,6 @@ def fixture( f"build-{name}-{architecture}-coverage", inputs=(discovery.name,) ) nodes.append(producer) - artifacts.append( - CoverageArtifact( - architecture, - name, - producer, - paths.cas(producer.uid, producer.name).output / filename, - ) - ) upstream = Graph(tuple(nodes), tuple(item.name for item in nodes[1:]), {"build": 8}) tools = root / "tools" tools.mkdir() @@ -77,16 +66,15 @@ def fixture( } for path in selected.values(): path.touch() - return repository, upstream, tuple(artifacts), selected + return repository, upstream, selected def graph(self, root: Path, **options: object) -> Graph: - repository, upstream, artifacts, tools = self.fixture( - root, options.pop("architectures", ("x64", "x86")) # type: ignore[arg-type] - ) + architectures = options.pop("architectures", ("x64", "x86")) + repository, upstream, tools = self.fixture(root, architectures) # type: ignore[arg-type] return coverage_artifact_graph( repository, upstream, - artifacts, + architectures=architectures, # type: ignore[arg-type] pwsh=tools["pwsh.exe"], pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, llvm_profdata=tools["llvm-profdata.exe"], @@ -134,6 +122,13 @@ def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( self.assertIn("'2'", script) self.assertIn("'--shard-index'", script) self.assertIn(f"'{index}'", script) + self.assertEqual( + tuple((item.id, item.relative_path) for item in shard.results), + (( + f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml", + "tests.xml", + ),), + ) merge = graph.node(f"coverage-merge-{architecture}") self.assertEqual(merge.inputs, tuple(item.name for item in shards)) @@ -144,7 +139,7 @@ def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( self.assertIn("$arguments = @('merge', '-sparse') + $profiles", merge_script) self.assertIn("llvm-profdata.exe' $arguments", merge_script) for shard in shards: - self.assertIn(str(paths.cas(shard.uid, shard.name).output), merge_script) + self.assertIn(str(paths.cas(shard.uid).output), merge_script) reports = tuple( graph.node(f"coverage-{kind}-{architecture}") for kind in ("json", "lcov") @@ -158,6 +153,23 @@ def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( self.assertIn("--ignore-filename-regex", script) for module in ("renpy.so", "rpgmaker.so", "zanzarah.so"): self.assertIn(module, script) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in reports[0].results), + (( + f"reports/coverage/cpp/{architecture}/coverage.json", + "coverage", "application/json", "coverage.json", + ),), + ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in reports[1].results), + (( + f"reports/coverage/cpp/{architecture}/coverage.lcov", + "coverage", "text/plain", "coverage.lcov", + ),), + ) + self.assertEqual(merge.results, ()) self.assertIn("--summary-only", reports[0].command.stdin.decode("utf-8")) self.assertIn("--format=lcov", reports[1].command.stdin.decode("utf-8")) @@ -166,17 +178,18 @@ def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( self.assertEqual(gate.pool, "coverage-gate") self.assertEqual(gate.command.argv[1:4], ("-m", "core.cpp_coverage", "gate")) self.assertNotIn("threshold", " ".join(gate.command.argv).casefold()) + self.assertEqual(gate.results, ()) def test_tool_identities_invalidate_only_their_nodes_and_semantic_consumers(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + repository, upstream, tools = self.fixture(root, ("x64",)) def build(profdata_version: str, cov_version: str) -> Graph: return coverage_artifact_graph( repository, upstream, - artifacts, + architectures=("x64",), pwsh=tools["pwsh.exe"], pwsh_identity={"version": "7.5"}, llvm_profdata=tools["llvm-profdata.exe"], @@ -201,7 +214,7 @@ def build(profdata_version: str, cov_version: str) -> Graph: def test_external_corpus_adds_independent_profile_shards_and_signs_only_path_and_nonce(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + repository, upstream, tools = self.fixture(root, ("x64",)) corpus = root / "corpus" moved_corpus = root / "moved-corpus" corpus.mkdir() @@ -211,7 +224,7 @@ def build(selected: Path | None = None, nonce: str = "") -> Graph: return coverage_artifact_graph( repository, upstream, - artifacts, + architectures=("x64",), pwsh=tools["pwsh.exe"], pwsh_identity={"version": "7.5"}, llvm_profdata=tools["llvm-profdata.exe"], @@ -230,6 +243,13 @@ def build(selected: Path | None = None, nonce: str = "") -> Graph: content_changed = build(corpus, "run-one") rerun = build(corpus, "run-two") moved = build(moved_corpus, "run-one") + cas_outputs = { + name: BuildPaths(repository).cas(first.node(name).uid).output + for name in ( + *(f"coverage-test-x64-{index}" for index in range(2)), + *(f"coverage-corpus-x64-{index}" for index in range(2)), + ) + } standard_names = tuple(f"coverage-test-x64-{index}" for index in range(2)) corpus_names = tuple(f"coverage-corpus-x64-{index}" for index in range(2)) @@ -250,26 +270,36 @@ def build(selected: Path | None = None, nonce: str = "") -> Graph: script = shard.command.stdin.decode("utf-8") self.assertIn("'[compatibility]'", script) self.assertIn("LLVM_PROFILE_FILE", script) + self.assertEqual( + tuple((item.id, item.relative_path) for item in shard.results), + (( + f"reports/coverage/cpp/x64/corpus/shard-{index}.xml", + "tests.xml", + ),), + ) merge = first.node("coverage-merge-x64") self.assertEqual(merge.inputs, standard_names + corpus_names) for name in (*standard_names, *corpus_names): - output = repository / "out/cas" / f"{first.node(name).uid}-{name}" / "out" - self.assertIn(str(output), merge.command.stdin.decode()) + self.assertIn(str(cas_outputs[name]), merge.command.stdin.decode()) self.assertTrue(all( "OBSERVER_TEST_CORPUS" not in dict(node.command.env) for node in first.nodes if not node.name.startswith("coverage-corpus-") )) - def test_adapter_rejects_incomplete_ambiguous_or_untrusted_build_outputs(self) -> None: + def test_rejects_invalid_axes_lineage_tools_pools_and_corpus(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, tools = self.fixture(root, ("x64",)) + repository, upstream, tools = self.fixture(root, ("x64",)) - def invoke(selected: tuple[CoverageArtifact, ...], source: Graph = upstream, **options: object) -> Graph: + def invoke( + source: Graph = upstream, + architectures: tuple[str, ...] = ("x64",), + **options: object, + ) -> Graph: return coverage_artifact_graph( repository, source, - selected, + architectures=architectures, pwsh=options.pop("pwsh", tools["pwsh.exe"]), # type: ignore[arg-type] pwsh_identity={"version": "7.5"}, llvm_profdata=tools["llvm-profdata.exe"], @@ -279,51 +309,22 @@ def invoke(selected: tuple[CoverageArtifact, ...], source: Graph = upstream, **o **options, ) - with self.assertRaisesRegex(ValueError, "complete.*set"): - invoke(artifacts[:-1]) - with self.assertRaisesRegex(ValueError, "at least one"): - invoke(()) - with self.assertRaisesRegex(ValueError, "duplicate"): - invoke(artifacts + (artifacts[0],)) - with self.assertRaisesRegex(ValueError, "identity"): - invoke((CoverageArtifact("armv7", "renpy", artifacts[0].producer, artifacts[0].path),)) - with self.assertRaisesRegex(ValueError, "identity"): - invoke((CoverageArtifact("x64", "bad", artifacts[0].producer, artifacts[0].path),)) - - impostor = Node( - artifacts[0].producer.name, - hashlib.md5(b"impostor", usedforsecurity=False).hexdigest(), - "build", - artifacts[0].producer.command, - ("restore-vcpkg-x64",), + for invalid in ((), ("x64", "x64"), ("armv7",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + invoke(architectures=invalid) + + missing = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-coverage"), + tuple(name for name in upstream.targets if name != "build-tests-x64-coverage"), + upstream.pools, ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((CoverageArtifact("x64", "renpy", impostor, artifacts[0].path), *artifacts[1:])) - wrong_producer = node("build-renpy-x64-debug", inputs=("restore-vcpkg-x64",)) - wrong_source = Graph(upstream.nodes + (wrong_producer,), upstream.targets, upstream.pools) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke( - ( - CoverageArtifact( - "x64", "renpy", wrong_producer, - BuildPaths(repository).cas(wrong_producer.uid, wrong_producer.name).output / "renpy.so", - ), - *artifacts[1:], - ), - wrong_source, - ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((CoverageArtifact("x64", "renpy", artifacts[0].producer, root / "outside.so"), *artifacts[1:])) - wrong_name = artifacts[0].path.with_name("wrong.so") - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((CoverageArtifact("x64", "renpy", artifacts[0].producer, wrong_name), *artifacts[1:])) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(missing) shared = node("detached-shared") left = node("detached-left", inputs=(shared.name,)) right = node("detached-right", inputs=(shared.name,)) - detached = node( - "build-renpy-x64-coverage", inputs=(left.name, right.name) - ) + detached = node("build-renpy-x64-coverage", inputs=(left.name, right.name)) detached_upstream = Graph( tuple( detached if current.name == detached.name else current @@ -332,37 +333,32 @@ def invoke(selected: tuple[CoverageArtifact, ...], source: Graph = upstream, **o upstream.targets, upstream.pools, ) - detached_artifacts = ( - CoverageArtifact( - "x64", "renpy", detached, - BuildPaths(repository).cas(detached.uid, detached.name).output / "renpy.so", - ), - *artifacts[1:], - ) with self.assertRaisesRegex(ValueError, "restore ancestor"): - invoke(detached_artifacts, detached_upstream) + invoke(detached_upstream) - for options in ( - {"test_shards": 0}, {"jobs": True}, {"report_jobs": 0}, - ): + for options in ({"test_shards": 0}, {"jobs": True}, {"report_jobs": 0}): with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"): - invoke(artifacts, **options) + invoke(**options) with self.assertRaisesRegex(FileNotFoundError, "not a file"): - invoke(artifacts, pwsh=tools["pwsh.exe"].parent) - conflicting = Graph(upstream.nodes, upstream.targets, dict(upstream.pools) | {"coverage-report": 1}) + invoke(pwsh=tools["pwsh.exe"].parent) + conflicting = Graph( + upstream.nodes, + upstream.targets, + dict(upstream.pools) | {"coverage-report": 1}, + ) with self.assertRaisesRegex(ValueError, "conflicting pool"): - invoke(artifacts, conflicting, report_jobs=2) + invoke(conflicting, report_jobs=2) corpus = root / "corpus" corpus.mkdir() for nonce in ("", True, "bad\0nonce"): with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "run nonce"): - invoke(artifacts, corpus=corpus, run_nonce=nonce) + invoke(corpus=corpus, run_nonce=nonce) with self.assertRaises(FileNotFoundError): - invoke(artifacts, corpus=root / "missing", run_nonce="run") + invoke(corpus=root / "missing", run_nonce="run") file_corpus = root / "corpus.bin" file_corpus.touch() with self.assertRaises(NotADirectoryError): - invoke(artifacts, corpus=file_corpus, run_nonce="run") + invoke(corpus=file_corpus, run_nonce="run") def test_complete_graph_discovers_and_builds_coverage_artifacts_itself(self) -> None: helper = instrumented_fixture.InstrumentedBuildGraphTests() diff --git a/build/tests/test_driver.py b/build/tests/test_driver.py index 377e2a6..afbd9fc 100644 --- a/build/tests/test_driver.py +++ b/build/tests/test_driver.py @@ -12,7 +12,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.graph import Command, Graph, Node # noqa: E402 +from core.graph import Command, Graph, GraphError, Node, Result # noqa: E402 from core.quality_tools import ResolvedDirectory, ResolvedTool # noqa: E402 import driver # noqa: E402 @@ -20,13 +20,16 @@ MODULES = ("renpy", "rpgmaker", "zanzarah") -def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: +def node( + name: str, *, inputs: tuple[str, ...] = (), results: tuple[Result, ...] = () +) -> Node: return Node( name, hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), "slot", Command(("true",)), inputs, + results, ) @@ -264,20 +267,27 @@ async def test_quality_commands_reject_invalid_contracts_before_discovery(self) await session.test_asan(("arm64",)) with self.assertRaisesRegex(ValueError, "UBSan does not support x86"): await session.test_ubsan(("x86",)) + with self.assertRaisesRegex(ValueError, "exactly one architecture"): + await session.verify_arch(("x86", "x64"), run_nonce="verify-run") coverage_discovery.assert_not_called() sanitizer_discovery.assert_not_called() - async def test_release_audit_package_and_leaks_map_exact_native_artifacts(self) -> None: + async def test_release_audit_package_and_leaks_pass_only_canonical_axes(self) -> None: session = self.session() dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh")) def native_discovery(_repository: Path, _toolchain: object, **options: object) -> Graph: return discovery_graph(options["architectures"]) - def audit_result(_repository: Path, upstream: Graph, artifacts: object, **_options: object) -> Graph: + def audit_result(_repository: Path, upstream: Graph, **options: object) -> Graph: + modules = (("leak-probe",) if options["include_leak_probe"] else ()) + MODULES gates = tuple( - node(f"audit-pe-{item.architecture}-{item.module}", inputs=(item.producer.name,)) - for item in artifacts + node( + f"audit-pe-{architecture}-{module}", + inputs=(f"build-{module}-{architecture}-release",), + ) + for architecture in options["architectures"] + for module in modules ) return Graph(upstream.nodes + gates, tuple(item.name for item in gates), upstream.pools | {"audit": 4}) @@ -295,14 +305,13 @@ def audit_result(_repository: Path, upstream: Graph, artifacts: object, **_optio mock.patch.object(driver, "runnable_architectures", return_value=("x86", "x64")) as runnable, mock.patch.object(driver, "require_runnable", return_value=("x64",)) as require, ): - await session.audit(("x64",), dumpbin=dumpbin, binskim=binskim, binskim_jobs=2) + await session.audit(("x64",), dumpbin=dumpbin, binskim=binskim) result = await session.package( - ("x86", "x64", "arm64"), dumpbin=dumpbin, binskim=binskim, binskim_jobs=2 + ("x86", "x64", "arm64"), dumpbin=dumpbin, binskim=binskim ) await session.test_leaks( run_nonce="leak-run", dumpbin=dumpbin, binskim=binskim, umdh=umdh, - binskim_jobs=2, warmup=2, iterations=3, windows=4, tolerance_bytes=5, - session_jobs=1, diff_jobs=2, + warmup=2, iterations=3, windows=4, tolerance_bytes=5, ) self.assertEqual(result, expected_packages) @@ -311,22 +320,24 @@ def audit_result(_repository: Path, upstream: Graph, artifacts: object, **_optio self.assertEqual([call.kwargs["include_leak_probe"] for call in native.call_args_list], [False, False, True]) self.assertTrue(all(call.kwargs["runnable_architectures"] == () for call in native.call_args_list)) self.assertEqual(audit.call_count, 3) - first_artifacts = tuple(audit.call_args_list[0].args[2]) - self.assertEqual([item.module for item in first_artifacts], list(MODULES)) - self.assertTrue(all(item.path.name == f"{item.module}.so" for item in first_artifacts)) + self.assertTrue(all(len(call.args) == 2 for call in audit.call_args_list)) + self.assertEqual( + [call.kwargs["architectures"] for call in audit.call_args_list], + [("x64",), ("x86", "x64", "arm64"), ("x64",)], + ) + self.assertEqual( + [call.kwargs["include_leak_probe"] for call in audit.call_args_list], + [False, False, True], + ) self.assertEqual(audit.call_args_list[0].kwargs["dumpbin_identity"], {"name": "dumpbin"}) self.assertEqual(audit.call_args_list[0].kwargs["binskim_identity"], {"name": "binskim"}) - package_artifacts = tuple(package.call_args.args[2]) - self.assertEqual(len(package_artifacts), 9) - self.assertTrue(all(item.symbols.name == f"{item.module}.pdb" for item in package_artifacts)) - smokes = tuple(package.call_args.kwargs["smoke_tests"]) - self.assertEqual([item.architecture for item in smokes], ["x86", "x64"]) - self.assertTrue(all(item.executable.name == "tests.exe" for item in smokes)) + self.assertTrue(all(call.kwargs["jobs"] == 4 for call in audit.call_args_list)) + self.assertTrue(all("binskim_jobs" not in call.kwargs for call in audit.call_args_list)) + self.assertEqual(package.call_args.kwargs["architectures"], ("x86", "x64", "arm64")) + self.assertEqual(package.call_args.kwargs["smoke_architectures"], ("x86", "x64")) runnable.assert_called_once_with(("x86", "x64", "arm64")) outputs.assert_called_once_with(self.repository, package_result) - leak_artifacts = tuple(leak.call_args.args[2]) - self.assertEqual([item.module for item in leak_artifacts], ["leak-probe", *MODULES]) - self.assertEqual(leak_artifacts[0].path.name, "leak-probe.exe") + self.assertEqual(len(leak.call_args.args), 2) self.assertEqual(leak.call_args.kwargs["umdh"], umdh.path) self.assertEqual(leak.call_args.kwargs["umdh_identity"], {"name": "umdh"}) self.assertEqual(leak.call_args.kwargs["run_nonce"], "leak-run") @@ -334,8 +345,9 @@ def audit_result(_repository: Path, upstream: Graph, artifacts: object, **_optio self.assertEqual(leak.call_args.kwargs["iterations"], 3) self.assertEqual(leak.call_args.kwargs["windows"], 4) self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 5) - self.assertEqual(leak.call_args.kwargs["session_jobs"], 1) - self.assertEqual(leak.call_args.kwargs["diff_jobs"], 2) + self.assertEqual(leak.call_args.kwargs["jobs"], 4) + self.assertNotIn("session_jobs", leak.call_args.kwargs) + self.assertNotIn("diff_jobs", leak.call_args.kwargs) require.assert_called_once_with(("x64",)) async def test_verify_runs_one_discovery_union_then_one_parallel_family_union(self) -> None: @@ -353,19 +365,74 @@ def discovered(_repository: Path, _toolchain: object, **options: object) -> Grap architectures = options.get("architectures", ("x64",)) return discovery_graph(architectures) + representative_results = { + "native": Result( + "reports/tests/x64/debug/unit/shard-0.xml", + "test", "application/xml", "tests.xml", + ), + "analysis": Result( + "reports/sarif/x64/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "source": Result( + "reports/sarif/source/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "cppcheck": Result( + "reports/sarif/x86-x64-arm64/cppcheck.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "python": Result( + "reports/coverage/python/coverage.json", + "coverage", "application/json", "coverage.json", + ), + "coverage": Result( + "reports/coverage/cpp/x64/coverage.json", + "coverage", "application/json", "coverage.json", + ), + "sanitizer": Result( + "reports/sanitizers/asan/x64/shard-0.xml", + "test", "application/xml", "tests.xml", + ), + "fuzz": Result( + "reports/fuzz/x64/pickle/status.txt", + "fuzz", "text/plain", "status.txt", + ), + "audit": Result( + "reports/sarif/x64/binskim-renpy.sarif", + "report", "application/sarif+json", "binskim.sarif", + ), + "package": Result( + "packages/x64/renpy-x64-dll.zip", + "package", "application/zip", "renpy-x64-dll.zip", + ), + "leak": Result( + "reports/leak/x64/operations/small-success/summary.json", + "leak-summary", "application/json", "summary.json", + ), + } + def family(name: str, upstream: Graph | None = None) -> Graph: pools = dict(upstream.pools) if upstream else {"slot": 4} nodes = upstream.nodes if upstream else () - marker = node(name) + marker = node(name, results=(representative_results[name],)) return Graph(nodes + (marker,), (marker.name,), pools) + def native_with_result( + repository: Path, toolchain: object, **options: object + ) -> Graph: + graph = native_result(repository, toolchain, **options) + marker = node("native-results", results=(representative_results["native"],)) + return Graph(graph.nodes + (marker,), graph.targets + (marker.name,), graph.pools) + source_tools = mock.Mock(return_value="source-tools") asan = mock.Mock(return_value=( ("x86", self.tool("asan-x86")), ("x64", self.tool("asan-x64")), )) resolve_ubsan = mock.Mock(return_value=ubsan) - native = mock.Mock(side_effect=native_result) - source = mock.Mock(return_value=family("source")) + native = mock.Mock(side_effect=native_with_result) + common_source = mock.Mock(return_value=family("source")) + architecture_source = mock.Mock(return_value=family("cppcheck")) coverage = mock.Mock(return_value=family("coverage")) sanitizer = mock.Mock(return_value=family("sanitizer")) fuzz = mock.Mock(return_value=family("fuzz")) @@ -392,7 +459,8 @@ def family(name: str, upstream: Graph | None = None) -> Graph: load_dependency_manifests=mock.Mock(return_value={"tu": b"{}"}), native_graph=native, analysis_slice=mock.Mock(return_value=family("analysis")), - source_checks_graph=source, + common_source_checks=common_source, + architecture_source_checks=architecture_source, python_coverage_graph=mock.Mock(return_value=family("python")), coverage_graph=coverage, sanitizer_graph=sanitizer, @@ -411,8 +479,14 @@ def family(name: str, upstream: Graph | None = None) -> Graph: self.assertEqual(len(session.runtime.executed), 2) final = session.runtime.executed[-1] - self.assertTrue({"analysis", "source", "python", "coverage", "sanitizer", "fuzz", + self.assertTrue({"analysis", "source", "cppcheck", "python", "coverage", "sanitizer", "fuzz", "package", "leak"}.issubset(final.targets)) + self.assertEqual( + {result.id for current in final.nodes for result in current.results}, + {result.id for result in representative_results.values()}, + ) + for result in representative_results.values(): + self.assertEqual(final.result(result.id)[1], result) self.assertEqual(native.call_args.kwargs["configurations"], ("Debug", "Release")) self.assertEqual(native.call_args.kwargs["runnable_architectures"], ("x86", "x64")) self.assertTrue(native.call_args.kwargs["include_leak_probe"]) @@ -424,15 +498,128 @@ def family(name: str, upstream: Graph | None = None) -> Graph: self.assertEqual(fuzz.call_args.kwargs["seconds"], 7) self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 6) self.assertEqual( - [item.architecture for item in package.call_args.kwargs["smoke_tests"]], - ["x86", "x64"], + package.call_args.kwargs["smoke_architectures"], + ("x86", "x64"), ) source_tools.assert_called_once_with(self.toolchain) - source.assert_called_once() + common_source.assert_called_once() + architecture_source.assert_called_once() asan.assert_called_once_with(self.toolchain, ("x86", "x64")) resolve_ubsan.assert_called_once_with(self.toolchain) - async def test_verify_omits_nonrunnable_specialists_but_keeps_build_audit_package(self) -> None: + async def test_verify_source_rejects_duplicate_result_ids_across_families(self) -> None: + session = self.session() + duplicate = Result( + "reports/sarif/source/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ) + common = Graph( + (node("source", results=(duplicate,)),), ("source",), {"slot": 4} + ) + python = Graph( + (node("python", results=(duplicate,)),), ("python",), {"slot": 4} + ) + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + self.assertRaisesRegex( + GraphError, + "duplicate result id: reports/sarif/source/analysis[.]sarif", + ), + ): + await session.verify_source() + + self.assertEqual(session.runtime.executed, []) + + async def test_verify_source_runs_only_common_source_and_python_coverage(self) -> None: + session = self.session() + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools") as tools, + mock.patch.object(driver, "common_source_checks", return_value=common) as source, + mock.patch.object(driver, "python_coverage_graph", return_value=python) as coverage, + mock.patch.object(driver, "export_results") as export, + ): + await session.verify_source(export_dir=self.repository / "evidence") + + self.assertEqual(len(session.runtime.executed), 1) + self.assertEqual(set(session.runtime.executed[0].targets), {"source", "python"}) + tools.assert_called_once_with(self.toolchain) + source.assert_called_once_with(self.repository, "tools", jobs=4) + coverage.assert_called_once_with(self.repository) + export.assert_called_once_with( + session.runtime.executed[0], session.runtime.store, + self.repository / "evidence", "verify-source", "success", failures=(), + ) + + async def test_failed_public_command_exports_structured_failures_before_reraising(self) -> None: + session = self.session() + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + failure = ExceptionGroup("failed", (RuntimeError("expected"),)) + failure.failed_nodes = ("source",) + + async def fail() -> None: + raise failure + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object(driver, "export_results") as export, + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception, failure) + graph = session.runtime.executed[0] + export.assert_called_once_with( + graph, session.runtime.store, self.repository / "failed-evidence", + "verify-source", "failed", failures=("source",), + ) + + async def test_export_failure_preserves_the_original_execution_failure(self) -> None: + session = self.session() + graph = Graph((node("source"),), ("source",), {"slot": 4}) + execution = ExceptionGroup("failed", (RuntimeError("execution"),)) + execution.failed_nodes = ("source",) + + async def fail() -> None: + raise execution + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=graph), + mock.patch.object( + driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 4}), + ), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object(driver, "export_results", side_effect=RuntimeError("export")), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], execution) + self.assertRegex(str(raised.exception.exceptions[1]), "export") + + async def test_failure_before_graph_composition_does_not_publish_empty_evidence(self) -> None: + session = self.session() + with ( + mock.patch.object( + driver, "discover_source_tools", side_effect=RuntimeError("discovery") + ), + mock.patch.object(driver, "export_results") as export, + self.assertRaisesRegex(RuntimeError, "discovery"), + ): + await session.verify_source(export_dir=self.repository / "evidence") + export.assert_not_called() + + async def test_verify_arch_omits_common_and_nonrunnable_specialists(self) -> None: session = self.session() route = SimpleNamespace( runnable=(), coverage=(), asan=(), ubsan=(), run_x64_specialists=False, @@ -450,7 +637,7 @@ def family(name: str, upstream: Graph | None = None) -> Graph: "resolve_umdh", "resolve_asan_runtimes", "resolve_ubsan_runtime", "coverage_dependency_discovery_slice", "sanitizer_dependency_discovery_slice", "fuzz_dependency_discovery_slice", "coverage_graph", "sanitizer_graph", - "fuzz_graph", "leak_graph", + "fuzz_graph", "leak_graph", "common_source_checks", "python_coverage_graph", ) } native = mock.Mock(side_effect=native_result) @@ -465,8 +652,7 @@ def family(name: str, upstream: Graph | None = None) -> Graph: load_dependency_manifests=mock.Mock(return_value={}), native_graph=native, analysis_slice=mock.Mock(return_value=family("analysis")), - source_checks_graph=mock.Mock(return_value=family("source")), - python_coverage_graph=mock.Mock(return_value=family("python")), + architecture_source_checks=mock.Mock(return_value=family("cppcheck")), audit_graph=mock.Mock( side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream) ), @@ -475,10 +661,10 @@ def family(name: str, upstream: Graph | None = None) -> Graph: ), **unused, ): - await session.verify(("arm64",), run_nonce="verify-run") + await session.verify_arch(("arm64",), run_nonce="verify-run") self.assertEqual(len(session.runtime.executed), 2) - self.assertTrue({"analysis", "source", "python", "package"}.issubset( + self.assertTrue({"analysis", "cppcheck", "package"}.issubset( session.runtime.executed[-1].targets )) self.assertFalse(native.call_args.kwargs["include_leak_probe"]) diff --git a/build/tests/test_execute.py b/build/tests/test_execute.py index bc13b3c..777ade6 100644 --- a/build/tests/test_execute.py +++ b/build/tests/test_execute.py @@ -206,36 +206,55 @@ async def runner(_current: Node) -> None: ).run() self.assertIn("broken", repr(raised.exception.subgroup(ExecutionError))) - async def test_task_group_cancels_running_siblings_on_first_failure(self) -> None: + async def test_failures_block_descendants_while_independent_successes_publish(self) -> None: graph = Graph( - (node("failure"), node("slow")), - ("failure", "slow"), - {"cpu": 2}, + ( + node("first-failure"), + node("blocked", inputs=("first-failure",)), + node("blocked-descendant", inputs=("blocked",)), + node("second-failure"), + node("independent"), + ), + ("blocked-descendant", "blocked", "first-failure", "second-failure", "independent"), + {"cpu": 3}, ) - slow_started = asyncio.Event() - slow_cancelled = asyncio.Event() + independent_started = asyncio.Event() + complete: set[str] = set() + calls: list[str] = [] async def runner(current: Node) -> None: - if current.name == "slow": - slow_started.set() - try: - await asyncio.sleep(60) - except asyncio.CancelledError: - slow_cancelled.set() - raise - await slow_started.wait() - raise RuntimeError("expected failure") + calls.append(current.name) + if current.name == "independent": + independent_started.set() + await asyncio.sleep(0.02) + return + await independent_started.wait() + if current.name == "first-failure": + raise ValueError("first expected failure") + if current.name == "second-failure": + raise RuntimeError("second expected failure") with self.assertRaises(ExceptionGroup) as raised: await Executor( graph, - is_complete=lambda _current: False, + is_complete=lambda current: current.name in complete, runner=runner, - publish=lambda _current: None, + publish=lambda current: complete.add(current.name), ).run() - self.assertIn("expected failure", repr(raised.exception.subgroup(RuntimeError))) - self.assertTrue(slow_cancelled.is_set()) + self.assertEqual(complete, {"independent"}) + self.assertCountEqual(calls, ["first-failure", "second-failure", "independent"]) + self.assertNotIn("blocked", calls) + self.assertNotIn("blocked-descendant", calls) + self.assertEqual(len(raised.exception.exceptions), 2) + self.assertEqual( + raised.exception.failed_nodes, + ("first-failure", "second-failure"), + ) + self.assertIn("first-failure", str(raised.exception)) + self.assertIn("second-failure", str(raised.exception)) + self.assertIn("first expected failure", repr(raised.exception.subgroup(ValueError))) + self.assertIn("second expected failure", repr(raised.exception.subgroup(RuntimeError))) async def test_completion_predicate_is_synchronous_and_returns_bool(self) -> None: graph = Graph((node("invalid"),), ("invalid",), {"cpu": 1}) diff --git a/build/tests/test_fuzz_graph.py b/build/tests/test_fuzz_graph.py index 6472fe5..901989a 100644 --- a/build/tests/test_fuzz_graph.py +++ b/build/tests/test_fuzz_graph.py @@ -208,6 +208,16 @@ def test_four_targets_are_independent_build_replay_and_bounded_run_branches(self self.assertEqual(replay.inputs, (build.name,)) self.assertEqual(run.inputs, (replay.name,)) self.assertEqual(gate.inputs, (run.name,)) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in run.results), + ( + (f"reports/fuzz/x64/{target}/status.txt", "fuzz", "text/plain", "status.txt"), + (f"reports/fuzz/x64/{target}/corpus", "corpus", "application/octet-stream", "corpus"), + (f"reports/fuzz/x64/{target}/artifacts", "evidence", "application/octet-stream", "artifacts"), + ), + ) + self.assertTrue(all(not item.results for item in (build, replay, gate))) self.assertEqual( (build.pool, replay.pool, run.pool, gate.pool), ("build", "fuzz", "fuzz", "fuzz"), @@ -356,7 +366,7 @@ def test_prior_published_corpus_seeds_and_signs_only_its_next_run(self) -> None: toolchain = self.toolchain(root) _discovery, before = self.graph(repository, toolchain, run_nonce="same") producer = before.node("run-fuzz-x64-pickle") - cas = BuildPaths(repository).cas(producer.uid, producer.name) + cas = BuildPaths(repository).cas(producer.uid) (cas.output / "corpus").mkdir(parents=True) (cas.output / "corpus/evolved").write_bytes(b"first") cas.log.write_text("green\n", encoding="utf-8") @@ -417,7 +427,7 @@ def test_prior_corpus_rejects_empty_or_nonfile_content(self) -> None: repository = self.repository(root / "repo") toolchain = self.toolchain(root) paths = BuildPaths(repository) - empty = paths.cas("1" * 32, "run-fuzz-x64-pickle") + empty = paths.cas("1" * 32) (empty.output / "corpus").mkdir(parents=True) empty.log.touch() empty.touch.touch() @@ -429,7 +439,7 @@ def test_prior_corpus_rejects_empty_or_nonfile_content(self) -> None: prior_corpora=(FuzzCorpusArtifact("pickle", "1" * 32),), ) - nonfile = paths.cas("2" * 32, "run-fuzz-x64-pickle") + nonfile = paths.cas("2" * 32) (nonfile.output / "corpus/directory").mkdir(parents=True) nonfile.log.touch() nonfile.touch.touch() diff --git a/build/tests/test_graph.py b/build/tests/test_graph.py index 58b0922..6a9fe86 100644 --- a/build/tests/test_graph.py +++ b/build/tests/test_graph.py @@ -9,7 +9,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.graph import Command, Graph, GraphError, Node, merge_graphs # noqa: E402 +from core.graph import Command, Graph, GraphError, Node, Result, merge_graphs # noqa: E402 def node( @@ -17,6 +17,7 @@ def node( *, inputs: tuple[str, ...] = (), pool: str = "cpu", + results: tuple[Result, ...] = (), ) -> Node: return Node( name=name, @@ -24,10 +25,66 @@ def node( pool=pool, command=Command(("tool", name)), inputs=inputs, + results=results, ) class GraphTests(unittest.TestCase): + def test_results_are_typed_and_addressable_without_scanning_directories(self) -> None: + report = Result( + "analysis/renpy/x64/sarif", + "report", + "application/sarif+json", + "analysis/renpy-x64.sarif", + ) + producer = node("analyze-renpy-x64", results=(report,)) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + self.assertEqual(producer.results, (report,)) + self.assertEqual(graph.result(report.id), (producer, report)) + with self.assertRaisesRegex(GraphError, "unknown result"): + graph.result("analysis/missing") + + def test_result_contract_rejects_ambiguous_identifiers_and_paths(self) -> None: + invalid = ( + (42, "report", "application/json", "report.json"), + ("Uppercase", "report", "application/json", "report.json"), + ("report", 42, "application/json", "report.json"), + ("report", "Report", "application/json", "report.json"), + ("report", "report", 42, "report.json"), + ("report", "report", "not-a-media-type", "report.json"), + ("report", "report", "application/json", 42), + ("report", "report", "application/json", ""), + ("report", "report", "application/json", "bad\0path"), + ("report", "report", "application/json", "../report.json"), + ("report", "report", "application/json", "reports/./report.json"), + ("report", "report", "application/json", "reports//report.json"), + ("report", "report", "application/json", r"reports\report.json"), + ("report", "report", "application/json", "/report.json"), + ("report", "report", "application/json", "C:/report.json"), + ) + for values in invalid: + with self.subTest(values=values), self.assertRaises(GraphError): + Result(*values) + + report = Result("report", "report", "application/json", "report.json") + with self.assertRaisesRegex(GraphError, "duplicate result ids"): + node("producer", results=(report, report)) + with self.assertRaisesRegex(GraphError, "results must be Result"): + Node( + "producer", + "0" * 32, + "cpu", + Command(("tool",)), + results=(object(),), # type: ignore[arg-type] + ) + with self.assertRaisesRegex(GraphError, "duplicate result id.*report"): + Graph( + (node("first", results=(report,)), node("second", results=(report,))), + ("first", "second"), + {"cpu": 1}, + ) + def test_graphs_merge_shared_exact_nodes_pools_and_targets(self) -> None: shared = node("shared") first = Graph( @@ -123,6 +180,8 @@ def test_ambiguous_command_and_node_data_are_rejected(self) -> None: invalid_commands = ( lambda: Command(()), lambda: Command(("bad\0argument",)), + lambda: Command(("tool",), env=(("incomplete",),)), # type: ignore[arg-type] + lambda: Command(("tool",), env=(("KEY", object()),)), # type: ignore[arg-type] lambda: Command(("tool",), env=(("PATH", "1"), ("Path", "2"))), lambda: Command(("tool",), cwd="bad\0cwd"), lambda: Command(("tool",), stdin="not bytes"), # type: ignore[arg-type] @@ -133,6 +192,8 @@ def test_ambiguous_command_and_node_data_are_rejected(self) -> None: with self.assertRaisesRegex(GraphError, "duplicate dependencies"): Node("safe", "0" * 32, "cpu", Command(("tool",)), ("same", "same")) + with self.assertRaisesRegex(GraphError, "command must be a Command"): + Node("safe", "0" * 32, "cpu", object()) # type: ignore[arg-type] def test_graph_container_and_lookups_are_explicit(self) -> None: only = node("only") diff --git a/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py index 3d54385..4e0b9aa 100644 --- a/build/tests/test_graph_main_coverage.py +++ b/build/tests/test_graph_main_coverage.py @@ -258,23 +258,6 @@ def test_empty_seed_corpus_and_nonpositive_duration_are_rejected(self) -> None: class NativeCoverageTests(unittest.TestCase): - def test_project_metadata_rejects_external_input_and_ignores_empty_items(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = Path(temporary) / "repo" - project = write( - repository, - "build/projects/sample.vcxproj", - '' - "", - ) - - inputs = native._project_inputs(repository, project) - - self.assertEqual(inputs[-1], "build/projects/sample.vcxproj") - self.assertEqual(len(inputs), len(native._COMMON_INPUTS) + 1) - with self.assertRaisesRegex(ValueError, "unsupported project input"): - native._relative(repository, "C:\\external.cpp", project) - def test_invalid_job_capacity_and_nonrunnable_release_leak_probe_contract(self) -> None: with self.assertRaisesRegex(ValueError, "jobs must be a positive integer"): native.native_graph( diff --git a/build/tests/test_instrumented_graph.py b/build/tests/test_instrumented_graph.py index 6e334bd..1cc61b1 100644 --- a/build/tests/test_instrumented_graph.py +++ b/build/tests/test_instrumented_graph.py @@ -156,7 +156,7 @@ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(s discovery = instrumented_dependency_discovery_slice( repository, toolchain, variants=variants, jobs=8 ) - graph, artifacts = instrumented_build_slice( + graph = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -200,10 +200,9 @@ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(s ("-m", "core.clang_dependencies", "scan"), ) self.assertNotIn("PlatformToolset=v143", asan_discovery) - self.assertEqual(len(graph.nodes), len(discovery.nodes) + len(artifacts)) + self.assertEqual(len(graph.nodes), len(discovery.nodes) + len(graph.targets)) self.assertEqual(len(graph.targets), 16) self.assertEqual(graph.pools, {"restore": 1, "slot": 8}) - self.assertEqual(len(artifacts), 16) for variant in variants: restore = ( @@ -211,12 +210,7 @@ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(s if variant.kind == "asan" else f"restore-vcpkg-{variant.architecture}" ) - for project, filename in ( - ("renpy", "renpy.so"), - ("rpgmaker", "rpgmaker.so"), - ("zanzarah", "zanzarah.so"), - ("tests", "tests.exe"), - ): + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): build = graph.node( f"build-{project}-{variant.architecture}-{variant.kind}" ) @@ -228,7 +222,7 @@ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(s self.assertIn(f"'/p:Configuration={variant.configuration}'", script) self.assertIn("'/m:1'", script) self.assertIn("'/p:BuildProjectReferences=false'", script) - self.assertIn(str(paths.cas(graph.node(restore).uid, restore).output), script) + self.assertIn(str(paths.cas(graph.node(restore).uid).output), script) if variant.kind in {"coverage", "ubsan"}: self.assertIn("'/p:LLVMInstallDir=", script) else: @@ -237,14 +231,6 @@ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(s self.assertIn("'/p:LLVMRuntimeDir=", script) else: self.assertNotIn("'/p:LLVMRuntimeDir=", script) - artifact = next( - item - for item in artifacts - if (item.kind, item.architecture, item.name) - == (variant.kind, variant.architecture, project) - ) - self.assertEqual(artifact.producer, build) - self.assertEqual(artifact.path, paths.cas(build.uid, build.name).output / filename) def test_invalid_variants_manifests_and_runtime_contracts_fail_closed(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -412,7 +398,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer repository, toolchain, variants=variants ) manifests = self.manifests(repository, discovery) - before, _artifacts = instrumented_build_slice( + before = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -424,7 +410,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer clang_discovery = instrumented_dependency_discovery_slice( repository, toolchain, variants=variants ) - clang_changed, _artifacts = instrumented_build_slice( + clang_changed = instrumented_build_slice( repository, toolchain, discovery=clang_discovery, @@ -437,7 +423,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer scanner_discovery = instrumented_dependency_discovery_slice( repository, toolchain, variants=variants ) - scanner_changed, _artifacts = instrumented_build_slice( + scanner_changed = instrumented_build_slice( repository, toolchain, discovery=scanner_discovery, @@ -450,7 +436,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer variants[1], InstrumentedVariant("ubsan", "x64", runtime, {"hash": "after"}), ) - runtime_changed, _artifacts = instrumented_build_slice( + runtime_changed = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -460,7 +446,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer (repository / "src/renpy.h").write_text( "#pragma once\n// changed\n", encoding="utf-8" ) - source_changed, _artifacts = instrumented_build_slice( + source_changed = instrumented_build_slice( repository, toolchain, discovery=discovery, @@ -473,7 +459,7 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer changed_discovery = instrumented_dependency_discovery_slice( repository, changed_toolchain, variants=variants ) - toolchain_changed, _artifacts = instrumented_build_slice( + toolchain_changed = instrumented_build_slice( repository, changed_toolchain, discovery=changed_discovery, diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py index 759a2fb..b0a481c 100644 --- a/build/tests/test_leak_graph.py +++ b/build/tests/test_leak_graph.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -from dataclasses import replace import io import json import os @@ -19,7 +18,6 @@ import core.leak as leak # noqa: E402 from core.leak import LeakError, main as leak_main # noqa: E402 from core.paths import BuildPaths # noqa: E402 -from graphs.audit import BinaryArtifact # noqa: E402 from graphs.leak import LEAK_MODES, LEAK_SCENARIOS, leak_graph # noqa: E402 @@ -36,7 +34,7 @@ def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: class LeakGraphTests(unittest.TestCase): def fixture( self, root: Path - ) -> tuple[Path, Graph, tuple[BinaryArtifact, ...], Path]: + ) -> tuple[Path, Graph, Path]: repository = root / "repo" repository.mkdir() restore = node("restore-release") @@ -52,31 +50,17 @@ def fixture( for kind in ("pe", "binskim") ) upstream = Graph((restore, *builds, *audit_gates), tuple(item.name for item in builds), {"build": 4}) - paths = BuildPaths(repository) - artifacts = tuple( - BinaryArtifact( - "x64", - name, - producer, - paths.cas(producer.uid, producer.name).output - / ("leak-probe.exe" if name == "leak-probe" else f"{name}.so"), - ) - for name, producer in zip( - ("leak-probe", "renpy", "rpgmaker", "zanzarah"), builds, strict=True - ) - ) tools = root / "tools" tools.mkdir() umdh = tools / "umdh.exe" umdh.touch() - return repository, upstream, artifacts, umdh + return repository, upstream, umdh def build_graph(self, root: Path, **options: object) -> Graph: - repository, upstream, artifacts, umdh = self.fixture(root) + repository, upstream, umdh = self.fixture(root) return leak_graph( repository, upstream, - artifacts, umdh=umdh, umdh_identity={"version": "10.0"}, run_nonce="run-42", @@ -85,18 +69,18 @@ def build_graph(self, root: Path, **options: object) -> Graph: def test_default_graph_has_one_shared_setup_and_fourteen_independent_branches(self) -> None: with tempfile.TemporaryDirectory() as temporary: - graph = self.build_graph(Path(temporary), jobs=7, session_jobs=3, diff_jobs=5) + graph = self.build_graph(Path(temporary), jobs=7) leak_nodes = tuple(item for item in graph.nodes if item.name.startswith("leak-")) - self.assertEqual(85, len(leak_nodes)) + self.assertEqual(99, len(leak_nodes)) self.assertEqual( graph.pools, - {"build": 4, "leak": 7, "leak-session": 3, "leak-diff": 5}, + {"build": 4, "slot": 7}, ) self.assertEqual( graph.targets, tuple( - f"leak-judge-{mode}-{scenario}" + f"leak-gate-{mode}-{scenario}" for mode in LEAK_MODES for scenario in LEAK_SCENARIOS ), @@ -127,20 +111,52 @@ def test_default_graph_has_one_shared_setup_and_fourteen_independent_branches(se graph.node(f"leak-diff-{stem}-window-{index}") for index in (1, 2) ) overall = graph.node(f"leak-diff-{stem}-overall") - judge = graph.node(f"leak-judge-{stem}") + summary = graph.node(f"leak-summary-{stem}") + gate = graph.node(f"leak-gate-{stem}") self.assertEqual(preflight.inputs, (setup.name,)) self.assertEqual(capture.inputs, (preflight.name,)) self.assertTrue(all(item.inputs == (capture.name,) for item in (*adjacent, overall))) - self.assertEqual(judge.inputs, tuple(item.name for item in (*adjacent, overall))) + self.assertEqual(summary.inputs, tuple(item.name for item in (*adjacent, overall))) + self.assertEqual(gate.inputs, (summary.name,)) self.assertEqual( - (preflight.pool, capture.pool, adjacent[0].pool, judge.pool), - ("leak", "leak-session", "leak-diff", "leak"), + (preflight.pool, capture.pool, adjacent[0].pool, summary.pool, gate.pool), + ("slot", "slot", "slot", "slot", "slot"), ) self.assertEqual(preflight.command.argv[-2:], (mode, scenario)) self.assertEqual(capture.command.argv[6:8], (mode, scenario)) self.assertEqual((overall.command.argv[3], overall.command.argv[6]), ("diff", "overall")) - self.assertEqual(judge.command.argv[9], "0") + self.assertEqual(summary.command.argv[9], "0") + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in preflight.results), + ((f"reports/leak/x64/{mode}/{scenario}/preflight.json", + "leak", "application/json", "preflight.json"),), + ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in capture.results), + ( + (f"reports/leak/x64/{mode}/{scenario}/capture.json", "capture.json"), + (f"reports/leak/x64/{mode}/{scenario}/snapshots", "snapshots"), + (f"reports/leak/x64/{mode}/{scenario}/probe.stderr.log", "probe.stderr.log"), + ), + ) + evidence = tuple(zip(("window-1", "window-2"), adjacent, strict=True)) + ( + ("overall", overall), + ) + for label, item in evidence: + self.assertEqual( + tuple((result.id, result.relative_path) for result in item.results), + ( + (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.json", "diff.json"), + (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.txt", "report.txt"), + ), + ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in summary.results), + ((f"reports/leak/x64/{mode}/{scenario}/summary.json", "summary.json"),), + ) + self.assertEqual(gate.results, ()) def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -149,12 +165,12 @@ def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None: root, warmup=2, iterations=3, windows=4, tolerance_bytes=17 ) - self.assertEqual(99, len(tuple(item for item in graph.nodes if item.name.startswith("leak-")))) + self.assertEqual(113, len(tuple(item for item in graph.nodes if item.name.startswith("leak-")))) capture = graph.node("leak-capture-operations-small-success") self.assertEqual(capture.command.argv[-3:], ("2", "3", "4")) - judge = graph.node("leak-judge-operations-small-success") + summary = graph.node("leak-summary-operations-small-success") self.assertEqual( - judge.inputs, + summary.inputs, ( "leak-diff-operations-small-success-window-1", "leak-diff-operations-small-success-window-2", @@ -162,12 +178,12 @@ def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None: "leak-diff-operations-small-success-overall", ), ) - self.assertEqual(judge.command.argv[3:10], ("judge", "operations", "small-success", "2", "3", "4", "17")) + self.assertEqual(summary.command.argv[3:10], ("summarize", "operations", "small-success", "2", "3", "4", "17")) def test_run_and_tool_identities_invalidate_only_the_measurement_partition(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, umdh = self.fixture(root) + repository, upstream, umdh = self.fixture(root) def graph( *, @@ -178,7 +194,6 @@ def graph( return leak_graph( repository, upstream, - artifacts, umdh=umdh, umdh_identity={"version": umdh_version}, run_nonce=nonce, @@ -193,19 +208,18 @@ def graph( for current in before.nodes: if not current.name.startswith("leak-"): continue - measured = current.name.startswith(("leak-capture-", "leak-diff-", "leak-judge-")) - judged = current.name.startswith("leak-judge-") + measured = current.name.startswith(("leak-capture-", "leak-diff-", "leak-summary-", "leak-gate-")) + judged = current.name.startswith(("leak-summary-", "leak-gate-")) self.assertEqual(measured, current.uid != rerun.node(current.name).uid, current.name) self.assertEqual(measured, current.uid != new_umdh.node(current.name).uid, current.name) self.assertEqual(judged, current.uid != new_tolerance.node(current.name).uid, current.name) - def test_rejects_invalid_tools_artifacts_pools_and_measurement_options(self) -> None: + def test_rejects_invalid_tools_lineage_pools_and_measurement_options(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, umdh = self.fixture(root) + repository, upstream, umdh = self.fixture(root) def invoke( - selected: tuple[BinaryArtifact, ...] = artifacts, *, source: Graph = upstream, umdh_path: Path = umdh, @@ -215,13 +229,10 @@ def invoke( windows: object = 3, tolerance: object = 4096, jobs: object = 4, - session_jobs: object = 2, - diff_jobs: object = 4, ) -> Graph: return leak_graph( repository, source, - selected, umdh=umdh_path, umdh_identity={"version": "10.0"}, run_nonce=nonce, # type: ignore[arg-type] @@ -230,15 +241,13 @@ def invoke( windows=windows, # type: ignore[arg-type] tolerance_bytes=tolerance, # type: ignore[arg-type] jobs=jobs, # type: ignore[arg-type] - session_jobs=session_jobs, # type: ignore[arg-type] - diff_jobs=diff_jobs, # type: ignore[arg-type] ) - for capacities in ((True, 2, 4), (4, "two", 4), (4, 2, 0)): - with self.subTest(capacities=capacities), self.assertRaisesRegex( + for jobs in (True, "four", 0): + with self.subTest(jobs=jobs), self.assertRaisesRegex( ValueError, "capacities" ): - invoke(jobs=capacities[0], session_jobs=capacities[1], diff_jobs=capacities[2]) + invoke(jobs=jobs) for counts in ((True, 100, 3), (8, "many", 3), (8, 100, 0)): with self.subTest(counts=counts), self.assertRaisesRegex(ValueError, "counts"): invoke(warmup=counts[0], iterations=counts[1], windows=counts[2]) @@ -253,16 +262,19 @@ def invoke( with self.assertRaisesRegex(FileNotFoundError, "not a file"): invoke(umdh_path=umdh.parent) - with self.assertRaisesRegex(ValueError, "missing"): - invoke(artifacts[:-1]) - with self.assertRaisesRegex(ValueError, "duplicate"): - invoke((*artifacts, artifacts[0])) - for invalid in ( - replace(artifacts[0], architecture="x86"), - replace(artifacts[0], module="unknown"), - ): - with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "invalid"): - invoke((invalid, *artifacts[1:])) + + missing_names = { + "build-leak-probe-x64-release", + "audit-pe-x64-leak-probe", + "audit-binskim-x64-leak-probe", + } + missing_build = Graph( + tuple(item for item in upstream.nodes if item.name not in missing_names), + tuple(name for name in upstream.targets if name != "build-leak-probe-x64-release"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=missing_build) missing_gate = Graph( tuple(node for node in upstream.nodes if node.name != "audit-binskim-x64-renpy"), @@ -272,39 +284,13 @@ def invoke( with self.assertRaisesRegex(ValueError, "audit gates"): invoke(source=missing_gate) - impostor = Node( - artifacts[0].producer.name, - artifacts[0].producer.uid, - "build", - Command(("other-build.exe",)), - artifacts[0].producer.inputs, - ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((replace(artifacts[0], producer=impostor), *artifacts[1:])) - unknown = node("unknown-producer") - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((replace(artifacts[0], producer=unknown), *artifacts[1:])) - - paths = BuildPaths(repository) - producer_output = paths.cas(artifacts[0].producer.uid, artifacts[0].producer.name).output - bad_paths = ( - producer_output / "wrong-name.exe", - paths.cas(artifacts[1].producer.uid, artifacts[1].producer.name).output - / "leak-probe.exe", - ) - for bad_path in bad_paths: - with self.subTest(bad_path=bad_path), self.assertRaisesRegex( - ValueError, "producer CAS" - ): - invoke((replace(artifacts[0], path=bad_path), *artifacts[1:])) - matching = Graph( upstream.nodes, upstream.targets, - {"build": 4, "leak": 4, "leak-session": 2, "leak-diff": 4}, + {"build": 4, "slot": 4}, ) - self.assertEqual(4, invoke(source=matching).pools["leak"]) - conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "leak": 1}) + self.assertEqual(4, invoke(source=matching).pools["slot"]) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "slot": 1}) with self.assertRaisesRegex(ValueError, "conflicting pool"): invoke(source=conflicting) @@ -484,6 +470,10 @@ def compare(argv: list[str], **_options: object) -> subprocess.CompletedProcess[ with self.output(root), self.assertRaisesRegex(LeakError, "sustained"): leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) self.assertFalse(json.loads((root / "summary.json").read_text())["passed"]) + with self.output(root): + leak_main(("summarize", "operations", "malformed", "1", "2", "3", "100", *paths)) + with self.assertRaisesRegex(LeakError, "sustained"): + leak_main(("gate", str(root / "summary.json"))) for path in (root / "window-1.json", root / "window-2.json", root / "overall.json"): document = json.loads(path.read_text()) @@ -492,6 +482,7 @@ def compare(argv: list[str], **_options: object) -> subprocess.CompletedProcess[ path.write_text(json.dumps(document)) with self.output(root): leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) + leak_main(("gate", str(root / "summary.json"))) self.assertTrue(json.loads((root / "summary.json").read_text())["passed"]) def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> None: @@ -519,6 +510,16 @@ def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> No with mock.patch.object(sys, "argv", ["leak.py", "unknown"]), self.assertRaises(LeakError): leak_main() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + invalid = root / "summary.json" + with self.output(root): + with self.assertRaisesRegex(LeakError, "summary is invalid"): + leak_main(("gate", str(invalid))) + invalid.write_text('{"passed":"yes"}', encoding="utf-8") + with self.assertRaisesRegex(LeakError, "summary is invalid"): + leak_main(("gate", str(invalid))) + process = FakeProbe([]) process.descendant.kill.side_effect = leak.psutil.NoSuchProcess(88) with mock.patch("core.leak.psutil.wait_procs") as waited: diff --git a/build/tests/test_main.py b/build/tests/test_main.py index 0842bb8..e02e6f8 100644 --- a/build/tests/test_main.py +++ b/build/tests/test_main.py @@ -73,22 +73,31 @@ def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> ("test", ["-Config", "Release", "-Corpus", "golden", "-TestShards", "7"], (("x64",), ("Release",)), {"test_shards": 7, "corpus": Path("golden"), "run_nonce": "run-id"}), ("compiler_analysis", [], (("x64",),), {}), - ("test_coverage", ["-Corpus", "golden", "-CoverageThreshold", "100"], + ("test_coverage", ["-Corpus", "golden"], (("x64",),), {"test_shards": 4, "corpus": Path("golden"), "run_nonce": "run-id"}), ("test_asan", ["-Arch", "x86,x64"], (("x86", "x64"),), {"test_shards": 4}), ("test_ubsan", [], (("x64",),), {"test_shards": 4}), ("fuzz", ["-FuzzSeconds", "91", "-FuzzTarget", "RenPy"], (), {"run_nonce": "run-id", "seconds": 91, "targets": ("renpy",)}), + ("verify_source", ["-ExportDir", "source-evidence"], (), + {"export_dir": Path("source-evidence")}), + ("verify_arch", ["-Arch", "x86", "-ExportDir", "x86-evidence", + "-FuzzSeconds", "17"], (("x86",),), + {"corpus": None, "run_nonce": "run-id", "fuzz_seconds": 17, + "test_shards": 4, "warmup": 8, "iterations": 100, "windows": 3, + "tolerance_bytes": 0, "export_dir": Path("x86-evidence")}), ("verify", ["-Arch", "all", "-Corpus", "golden", "-FuzzSeconds", "17", "-LeakWarmup", "2", "-LeakIterations", "5", "-LeakWindows", "4", - "-LeakToleranceBytes", "9"], (("x86", "x64", "arm64"),), + "-LeakToleranceBytes", "9", "-ExportDir", "all-evidence"], + (("x86", "x64", "arm64"),), {"corpus": Path("golden"), "run_nonce": "run-id", "fuzz_seconds": 17, "test_shards": 4, "warmup": 2, "iterations": 5, "windows": 4, - "tolerance_bytes": 9}), + "tolerance_bytes": 9, "export_dir": Path("all-evidence")}), ) command_names = { "compiler_analysis": "compiler-analysis", "test_coverage": "test-coverage", "test_asan": "test-asan", "test_ubsan": "test-ubsan", + "verify_source": "verify-source", "verify_arch": "verify-arch", } for method, options, positional, keywords in cases: with self.subTest(command=method): @@ -126,10 +135,17 @@ def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> for command, method in (("audit-binaries", "audit"), ("package", "package")): with self.subTest(command=command): - result = self.invoke([command, "-Repository", str(repository), "-Jobs", "3"]) + options = [command, "-Repository", str(repository), "-Jobs", "3"] + if command == "package": + options.extend(("-ExportDir", "package-evidence")) + result = self.invoke(options) dumpbin, binskim, _umdh = result.tools self.assertEqual(FakeDriver.instances[-1].calls, [ - (method, (("x64",),), {"dumpbin": dumpbin, "binskim": binskim}) + (method, (("x64",),), { + "dumpbin": dumpbin, + "binskim": binskim, + **({"export_dir": Path("package-evidence")} if command == "package" else {}), + }) ]) leak = self.invoke([ @@ -179,7 +195,10 @@ async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]: stdout = StringIO() with ( - mock.patch.dict(main._INVOKE, {"verify": fail}), + mock.patch.dict( + main._COMMANDS, + {"verify": (main._COMMANDS["verify"][0], fail)}, + ), mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=(deferred,))), mock.patch.object(main, "discover_msvc_toolchain", return_value=object()), mock.patch.object(main, "BuildDriver", FakeDriver), @@ -189,17 +208,20 @@ async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]: main.main(["verify", "-Arch", "arm64"]) self.assertEqual(stdout.getvalue(), "") - def test_help_and_deprecated_skip_restore_contract(self) -> None: + def test_help_and_removed_skip_restore_contract(self) -> None: for argv in ([], ["help"]): with self.subTest(argv=argv), redirect_stdout(StringIO()) as stdout: self.assertEqual(main.main(argv), 0) self.assertIn("audit-binaries", stdout.getvalue()) - result = self.invoke(["build", "-SkipDependencyRestore"]) - self.assertEqual(FakeDriver.instances[-1].calls[0][0], "build") - with redirect_stderr(StringIO()), self.assertRaises(SystemExit) as raised: - main.main(["restore", "-SkipDependencyRestore"]) - self.assertEqual(raised.exception.code, 2) + for command in ("build", "restore"): + with ( + self.subTest(command=command), + redirect_stderr(StringIO()), + self.assertRaises(SystemExit) as raised, + ): + main.main([command, "-SkipDependencyRestore"]) + self.assertEqual(raised.exception.code, 2) def test_invalid_legacy_options_fail_before_discovery(self) -> None: cases = ( @@ -212,8 +234,10 @@ def test_invalid_legacy_options_fail_before_discovery(self) -> None: ["test-leaks", "-LeakWindows", "2"], ["test-leaks", "-LeakWindows", "11"], ["test-leaks", "-LeakToleranceBytes", "-1"], ["test-coverage", "-CoverageThreshold", "99"], + ["test-coverage", "-CoverageThreshold", "100"], ["test-coverage", "-CoverageThreshold", "100.0"], ["test", "-TestShards", "0"], ["build", "-Jobs", "0"], + ["verify-arch", "-Arch", "all"], ) for argv in cases: with ( diff --git a/build/tests/test_native_graph.py b/build/tests/test_native_graph.py index b2727ab..6a24a2a 100644 --- a/build/tests/test_native_graph.py +++ b/build/tests/test_native_graph.py @@ -198,6 +198,19 @@ def test_projects_restore_and_test_shards_form_a_fine_grained_dag(self) -> None: self.assertIn("'--shard-index'", shard_script) self.assertIn("'0'", shard_script) self.assertIn("'JUnit::out=", shard_script) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in shard.results), + (( + "reports/tests/x64/debug/unit/shard-0.xml", + "test", "application/xml", "tests.xml", + ),), + ) + self.assertTrue(all( + not node.results + for node in graph.nodes + if node.name.startswith(("restore-", "discover-", "build-")) + )) def test_leak_probe_is_an_explicit_release_only_request(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -265,6 +278,10 @@ def test_external_corpus_is_test_only_and_nonce_invalidated(self) -> None: self.assertEqual(dict(first.node(corpus_name).command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve())) self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(shard_name).command.env)) self.assertIn("'[compatibility]'", first.node(corpus_name).command.stdin.decode()) + self.assertEqual( + tuple((item.id, item.relative_path) for item in first.node(corpus_name).results), + (("reports/tests/x64/debug/corpus/shard-0.xml", "tests.xml"),), + ) self.assertTrue(all( "OBSERVER_TEST_CORPUS" not in dict(node.command.env) for node in first.nodes if not node.name.startswith("corpus-shard-") diff --git a/build/tests/test_node.py b/build/tests/test_node.py index 8be3c26..0573e65 100644 --- a/build/tests/test_node.py +++ b/build/tests/test_node.py @@ -9,11 +9,34 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) +from core.graph import Result # noqa: E402 from core.node import NodeFactory # noqa: E402 from core.render import TemplateRenderer # noqa: E402 class NodeFactoryTests(unittest.TestCase): + def test_declared_results_flow_from_factory_to_node(self) -> None: + renderer = TemplateRenderer(BUILD_ROOT / "templates") + report = Result( + "analysis/example/json", + "report", + "application/json", + "reports/example.json", + ) + with tempfile.TemporaryDirectory() as temporary: + factory = NodeFactory(renderer, Path(temporary), {"tool": "1"}) + current = factory.make( + "argv.json", + "example", + "slot", + {"argv": (str(Path(sys.executable).resolve()), "--version")}, + files={}, + results=(report,), + config={"action": "probe"}, + ) + + self.assertEqual(current.results, (report,)) + def test_runtime_environment_and_cwd_are_signed(self) -> None: renderer = TemplateRenderer(BUILD_ROOT / "templates") with tempfile.TemporaryDirectory() as temporary: diff --git a/build/tests/test_package_graph.py b/build/tests/test_package_graph.py index cf3b90d..fd94f74 100644 --- a/build/tests/test_package_graph.py +++ b/build/tests/test_package_graph.py @@ -18,7 +18,7 @@ from core.graph import Command, Graph, Node # noqa: E402 from core.package import PackageError, main as package_main # noqa: E402 from core.paths import BuildPaths # noqa: E402 -from graphs.package import PackageArtifact, PackageSmokeArtifact, package_graph, package_outputs # noqa: E402 +from graphs.package import package_graph, package_outputs # noqa: E402 MODULES = ("renpy", "rpgmaker", "zanzarah") @@ -48,9 +48,8 @@ def repository(self, root: Path) -> Path: def fixture( self, root: Path, architectures: tuple[str, ...] = ("x64", "arm64") - ) -> tuple[Path, Graph, tuple[PackageArtifact, ...]]: + ) -> tuple[Path, Graph]: repository = self.repository(root / "repo") - paths = BuildPaths(repository) producers = tuple( node(f"build-{module}-{architecture}-release") for architecture in architectures @@ -65,59 +64,69 @@ def fixture( ) for kind in ("pe", "binskim") ) - upstream = Graph((*producers, *audit_gates), tuple(item.name for item in producers), {"build": 4}) - artifacts = tuple( - PackageArtifact( - architecture, - module, - producer, - paths.cas(producer.uid, producer.name).output / f"{module}.so", - paths.cas(producer.uid, producer.name).output / f"{module}.pdb", - ) - for producer, (architecture, module) in zip( - producers, - ((architecture, module) for architecture in architectures for module in MODULES), - strict=True, - ) + tests = tuple(node(f"build-tests-{architecture}-release") for architecture in architectures) + upstream = Graph( + (*producers, *tests, *audit_gates), + tuple(item.name for item in producers), + {"build": 4}, ) - return repository, upstream, artifacts + return repository, upstream def test_package_units_fan_out_and_only_inherent_aggregates_fan_in(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository, upstream, artifacts = self.fixture(Path(temporary)) - graph = package_graph(repository, upstream, artifacts, jobs=7) + repository, upstream = self.fixture(Path(temporary)) + graph = package_graph( + repository, upstream, architectures=("x64", "arm64"), jobs=7 + ) paths = BuildPaths(repository) outputs = package_outputs(repository, graph) with self.assertRaisesRegex(ValueError, "no package outputs"): package_outputs(repository, upstream) + empty_manifest = Graph( + (node("package-manifest"),), ("package-manifest",), {"build": 1} + ) + with self.assertRaisesRegex(ValueError, "no package outputs"): + package_outputs(repository, empty_manifest) self.assertEqual(graph.pools, {"build": 4, "package": 7}) self.assertEqual(len(graph.nodes) - len(upstream.nodes), 29) self.assertEqual(graph.targets, ("package-manifest",)) - for artifact in artifacts: - suffix = f"{artifact.architecture}-{artifact.module}" + for architecture in ("arm64", "x64"): + for module in MODULES: + suffix = f"{architecture}-{module}" + producer = graph.node(f"build-{module}-{architecture}-release") stage = graph.node(f"package-stage-{suffix}") symbols = graph.node(f"package-symbol-stage-{suffix}") archive = graph.node(f"package-archive-{suffix}") validation = graph.node(f"package-validate-{suffix}") gates = ( - f"audit-pe-{artifact.architecture}-{artifact.module}", - f"audit-binskim-{artifact.architecture}-{artifact.module}", + f"audit-pe-{architecture}-{module}", + f"audit-binskim-{architecture}-{module}", ) - self.assertEqual(stage.inputs, (artifact.producer.name, *gates)) - self.assertEqual(symbols.inputs, (artifact.producer.name, *gates)) + self.assertEqual(stage.inputs, (producer.name, *gates)) + self.assertEqual(symbols.inputs, (producer.name, *gates)) self.assertEqual(archive.inputs, (stage.name, *gates)) self.assertEqual(validation.inputs, (archive.name, stage.name)) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in validation.results), + (( + f"reports/package/{architecture}/{module}/validation.json", + "package-validation", "application/json", "validation.json", + ),), + ) + self.assertEqual(archive.results, ()) self.assertEqual( validation.command.argv[3:], - ("validate-module", artifact.architecture, artifact.module, - str(paths.cas(archive.uid, archive.name).output / f"{artifact.module}-{artifact.architecture}-dll.zip"), - str(paths.cas(stage.uid, stage.name).output)), + ("validate-module", architecture, module, + str(paths.cas(archive.uid).output / f"{module}-{architecture}-dll.zip"), + str(paths.cas(stage.uid).output)), ) self.assertEqual(stage.command.argv[:3], (sys.executable, "-m", "core.package")) - self.assertIn(str(artifact.binary), stage.command.argv) - self.assertIn(str(artifact.symbols), symbols.command.argv) - self.assertIn(str(paths.cas(stage.uid, stage.name).output), archive.command.argv) + producer_output = paths.cas(producer.uid).output + self.assertIn(str(producer_output / f"{module}.so"), stage.command.argv) + self.assertIn(str(producer_output / f"{module}.pdb"), symbols.command.argv) + self.assertIn(str(paths.cas(stage.uid).output), archive.command.argv) for architecture in ("arm64", "x64"): combined = graph.node(f"package-symbols-{architecture}") @@ -130,37 +139,52 @@ def test_package_units_fan_out_and_only_inherent_aggregates_fan_in(self) -> None validation.inputs, (combined.name, *combined.inputs), ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in validation.results), + ((f"reports/package/{architecture}/symbols/validation.json", "validation.json"),), + ) aggregate = graph.node("package-manifest") self.assertEqual(len(aggregate.inputs), 8) self.assertTrue(all(name.startswith("package-validate-") or name.startswith("package-symbols-validate-") for name in aggregate.inputs)) + expected_names = { + *(f"{module}-{architecture}-dll.zip" + for architecture in ("arm64", "x64") for module in MODULES), + *(f"observer-modules-{architecture}-pdb.zip" + for architecture in ("arm64", "x64")), + } self.assertEqual( - {Path(argument).name for argument in aggregate.command.argv[4:]}, + {(item.id, item.kind, item.media_type, item.relative_path) + for item in aggregate.results}, { - *(f"{module}-{architecture}-dll.zip" for architecture in ("arm64", "x64") for module in MODULES), - *(f"observer-modules-{architecture}-pdb.zip" for architecture in ("arm64", "x64")), - }, + (f"packages/{name.split('-')[1] if '-modules-' not in name else name.split('-')[2]}/{name}", + "package", "application/zip", name) + for name in expected_names + } | {("packages/packages.json", "package-manifest", "application/json", "packages.json")}, + ) + self.assertEqual( + {Path(argument).name for argument in aggregate.command.argv[4:]}, + expected_names, ) self.assertTrue(all(node.pool == "package" for node in graph.nodes[len(upstream.nodes) :])) self.assertEqual( outputs, tuple( - paths.cas(graph.node(node_name).uid, node_name).output / archive_name + paths.cas(aggregate.uid).output / archive_name for architecture in ("arm64", "x64") - for node_name, archive_name in ( - *( (f"package-archive-{architecture}-{module}", f"{module}-{architecture}-dll.zip") - for module in MODULES ), - (f"package-symbols-{architecture}", f"observer-modules-{architecture}-pdb.zip"), + for archive_name in ( + *(f"{module}-{architecture}-dll.zip" for module in MODULES), + f"observer-modules-{architecture}-pdb.zip", ) ), ) def test_repository_metadata_invalidates_only_consuming_package_partition(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository, upstream, artifacts = self.fixture(Path(temporary), ("x64",)) - before = package_graph(repository, upstream, artifacts) + repository, upstream = self.fixture(Path(temporary), ("x64",)) + before = package_graph(repository, upstream, architectures=("x64",)) (repository / "src/modules/renpy/observer_user.ini").write_text("changed\n", encoding="utf-8") - after = package_graph(repository, upstream, artifacts) + after = package_graph(repository, upstream, architectures=("x64",)) changed = {current.name for current in before.nodes if current.uid != after.node(current.name).uid} self.assertEqual( @@ -173,20 +197,15 @@ def test_repository_metadata_invalidates_only_consuming_package_partition(self) def test_package_smokes_run_independently_against_exact_archives(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository, original, artifacts = self.fixture(Path(temporary), ("x64",)) - test_producer = node("build-tests-x64-release") - upstream = Graph( - original.nodes + (test_producer,), - original.targets + (test_producer.name,), - original.pools, - ) + repository, upstream = self.fixture(Path(temporary), ("x64",)) + test_producer = upstream.node("build-tests-x64-release") paths = BuildPaths(repository) - executable = paths.cas(test_producer.uid, test_producer.name).output / "tests.exe" + executable = paths.cas(test_producer.uid).output / "tests.exe" graph = package_graph( repository, upstream, - artifacts, - smoke_tests=(PackageSmokeArtifact("x64", test_producer, executable),), + architectures=("x64",), + smoke_architectures=("x64",), ) smokes = tuple(graph.node(f"package-smoke-x64-{module}") for module in MODULES) @@ -199,7 +218,7 @@ def test_package_smokes_run_independently_against_exact_archives(self) -> None: archive = graph.node(f"package-archive-x64-{module}") self.assertEqual( Path(smoke.command.argv[6]), - paths.cas(archive.uid, archive.name).output / f"{module}-x64-dll.zip", + paths.cas(archive.uid).output / f"{module}-x64-dll.zip", ) self.assertIn(str(executable), smoke.command.argv) manifest_inputs = set(graph.node("package-manifest").inputs) @@ -210,41 +229,31 @@ def test_package_smokes_run_independently_against_exact_archives(self) -> None: | {"package-symbols-validate-x64"}, ) - def test_invalid_artifacts_sets_and_pool_contracts_are_rejected(self) -> None: + def test_invalid_axes_lineage_and_pool_contracts_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository, upstream, artifacts = self.fixture(Path(temporary), ("x64",)) + repository, upstream = self.fixture(Path(temporary), ("x64",)) for jobs in (0, True, "four"): with self.subTest(jobs=jobs), self.assertRaisesRegex(ValueError, "positive integer"): - package_graph(repository, upstream, artifacts, jobs=jobs) # type: ignore[arg-type] - with self.assertRaisesRegex(ValueError, "at least one"): - package_graph(repository, upstream, ()) - with self.assertRaisesRegex(ValueError, "duplicate"): - package_graph(repository, upstream, artifacts + (artifacts[0],)) - with self.assertRaisesRegex(ValueError, "identity"): - package_graph( - repository, - upstream, - ( - PackageArtifact( - "mips", "renpy", artifacts[0].producer, - artifacts[0].binary, artifacts[0].symbols, - ), - ), - ) - with self.assertRaisesRegex(ValueError, "identity"): - package_graph( - repository, - upstream, - ( - PackageArtifact( - "x64", "bad module", artifacts[0].producer, - artifacts[0].binary, artifacts[0].symbols, - ), - ), - ) - with self.assertRaisesRegex(ValueError, "complete module set"): - package_graph(repository, upstream, artifacts[:-1]) + package_graph( + repository, upstream, architectures=("x64",), jobs=jobs # type: ignore[arg-type] + ) + for invalid in ((), ("x64", "x64"), ("mips",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + package_graph(repository, upstream, architectures=invalid) + + removed = { + "build-renpy-x64-release", + "audit-pe-x64-renpy", + "audit-binskim-x64-renpy", + } + missing_build = Graph( + tuple(item for item in upstream.nodes if item.name not in removed), + tuple(name for name in upstream.targets if name not in removed), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + package_graph(repository, missing_build, architectures=("x64",)) missing_gate = Graph( tuple(node for node in upstream.nodes if node.name != "audit-pe-x64-renpy"), @@ -252,18 +261,26 @@ def test_invalid_artifacts_sets_and_pool_contracts_are_rejected(self) -> None: upstream.pools, ) with self.assertRaisesRegex(ValueError, "audit gates"): - package_graph(repository, missing_gate, artifacts) + package_graph(repository, missing_gate, architectures=("x64",)) pe_gate = upstream.node("audit-pe-x64-renpy") - proof = node("audit-proof-x64-renpy", inputs=(artifacts[0].producer.name,)) + producer = upstream.node("build-renpy-x64-release") + proof = node("audit-proof-x64-renpy", inputs=(producer.name,)) indirect_gate = Node(pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (proof.name,)) indirect = Graph( (*tuple(item for item in upstream.nodes if item != pe_gate), proof, indirect_gate), upstream.targets, upstream.pools, ) - self.assertEqual(package_graph(repository, indirect, artifacts).targets, ("package-manifest",)) + self.assertEqual( + package_graph(repository, indirect, architectures=("x64",)).targets, + ("package-manifest",), + ) unrelated_gate = Node( - pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (artifacts[1].producer.name,) + pe_gate.name, + pe_gate.uid, + pe_gate.pool, + pe_gate.command, + (upstream.node("build-rpgmaker-x64-release").name,), ) unrelated = Graph( (*tuple(item for item in upstream.nodes if item != pe_gate), unrelated_gate), @@ -271,48 +288,40 @@ def test_invalid_artifacts_sets_and_pool_contracts_are_rejected(self) -> None: upstream.pools, ) with self.assertRaisesRegex(ValueError, "do not consume producer"): - package_graph(repository, unrelated, artifacts) - - impostor = node(artifacts[0].producer.name, "impostor") - bad_producer = PackageArtifact("x64", "renpy", impostor, artifacts[0].binary, artifacts[0].symbols) - with self.assertRaisesRegex(ValueError, "exact producer CAS"): - package_graph(repository, upstream, (bad_producer,) + artifacts[1:]) - wrong_path = PackageArtifact( - "x64", "renpy", artifacts[0].producer, artifacts[0].binary.parent / "wrong.so", artifacts[0].symbols - ) - with self.assertRaisesRegex(ValueError, "exact producer CAS"): - package_graph(repository, upstream, (wrong_path,) + artifacts[1:]) + package_graph(repository, unrelated, architectures=("x64",)) matching = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 4}) - self.assertEqual(package_graph(repository, matching, artifacts).pools["package"], 4) + self.assertEqual( + package_graph(repository, matching, architectures=("x64",)).pools["package"], 4 + ) conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 1}) with self.assertRaisesRegex(ValueError, "conflicting pool"): - package_graph(repository, conflicting, artifacts) + package_graph(repository, conflicting, architectures=("x64",)) - def test_invalid_package_smoke_artifacts_are_rejected(self) -> None: + def test_invalid_package_smoke_axes_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository, original, artifacts = self.fixture(Path(temporary), ("x64",)) - producer = node("build-tests-x64-release") - upstream = Graph(original.nodes + (producer,), original.targets, original.pools) - paths = BuildPaths(repository) - valid = PackageSmokeArtifact( - "x64", producer, paths.cas(producer.uid, producer.name).output / "tests.exe" - ) - cases = ( - ((valid, valid), "duplicate"), - ((PackageSmokeArtifact("x86", producer, valid.executable),), "exact test producer"), - ( - (PackageSmokeArtifact("x64", node(producer.name, "impostor"), valid.executable),), - "exact test producer", - ), - ( - (PackageSmokeArtifact("x64", producer, valid.executable.with_name("wrong.exe")),), - "exact test producer", - ), + repository, upstream = self.fixture(Path(temporary), ("x64",)) + for smokes in (("x64", "x64"), ("x86",)): + with self.subTest(smokes=smokes), self.assertRaisesRegex(ValueError, "smoke architectures"): + package_graph( + repository, + upstream, + architectures=("x64",), + smoke_architectures=smokes, + ) + + missing_test = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-release"), + upstream.targets, + upstream.pools, ) - for smokes, message in cases: - with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): - package_graph(repository, upstream, artifacts, smoke_tests=smokes) + with self.assertRaisesRegex(ValueError, "unknown node"): + package_graph( + repository, + missing_test, + architectures=("x64",), + smoke_architectures=("x64",), + ) class PackageActionTests(unittest.TestCase): @@ -393,6 +402,7 @@ def test_module_stage_archive_and_aggregate_are_reproducible(self) -> None: aggregate = root / "aggregate" self.invoke(aggregate, "aggregate", str(first)) + self.assertEqual((aggregate / first.name).read_bytes(), first.read_bytes()) packages = json.loads((aggregate / "packages.json").read_text(encoding="utf-8")) self.assertEqual("renpy-x64-dll.zip", packages[0]["name"]) self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), packages[0]["sha256"]) diff --git a/build/tests/test_paths.py b/build/tests/test_paths.py index 5774b70..18ad658 100644 --- a/build/tests/test_paths.py +++ b/build/tests/test_paths.py @@ -29,17 +29,17 @@ def test_prepare_creates_only_cas_and_work_at_output_root(self) -> None: self.assertTrue(paths.work_root.is_dir()) self.assertTrue(paths.locks_root.is_dir()) - def test_cas_paths_expose_entry_output_touch_and_log(self) -> None: + def test_cas_paths_use_only_uid_for_entry_output_touch_and_log(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" repository.mkdir() paths = BuildPaths(repository) - entry = paths.cas(UID, "analyze-renpy.pickle") + entry = paths.cas(UID) self.assertEqual( entry.entry, - repository / "out" / "cas" / f"{UID}-analyze-renpy.pickle", + repository / "out" / "cas" / UID, ) self.assertEqual(entry.output, entry.entry / "out") self.assertEqual(entry.touch, entry.entry / "touch") @@ -77,12 +77,7 @@ def test_invalid_uid_and_run_identifier_are_rejected(self) -> None: for invalid_uid in ("", "ABCDEF" * 5 + "AB", "../escape", "0" * 31): with self.subTest(uid=invalid_uid): with self.assertRaises(PathSafetyError): - paths.cas(invalid_uid, "node") - - for invalid_node in ("", "Uppercase", ".hidden", "../escape", "with/slash"): - with self.subTest(node=invalid_node): - with self.assertRaises(PathSafetyError): - paths.cas(UID, invalid_node) + paths.cas(invalid_uid) for invalid_run in ("", ".", "..", "../escape", "with/slash", "a" * 129): with self.subTest(run=invalid_run): @@ -91,17 +86,15 @@ def test_invalid_uid_and_run_identifier_are_rejected(self) -> None: with self.assertRaises(PathSafetyError): paths.lease(invalid_run) - def test_cas_node_slug_is_limited_to_128_ascii_characters(self) -> None: + def test_cas_accepts_only_the_content_uid(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" repository.mkdir() paths = BuildPaths(repository) - maximum = paths.cas(UID, "a" * 128) - - self.assertTrue(maximum.entry.name.endswith("-" + "a" * 128)) - with self.assertRaisesRegex(PathSafetyError, "node slug.*128"): - paths.cas(UID, "a" * 129) + self.assertEqual(paths.cas(UID).entry, repository / "out" / "cas" / UID) + with self.assertRaises(TypeError): + paths.cas(UID, "legacy-readable-name") # type: ignore[call-arg] def test_confined_path_rejects_parent_escape_and_unexpected_root(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -122,7 +115,7 @@ def test_existing_reparse_component_and_leaf_are_rejected(self) -> None: repository.mkdir() regular = BuildPaths(repository) regular.prepare() - entry = regular.cas(UID, "node") + entry = regular.cas(UID) entry.entry.mkdir() entry.touch.touch() @@ -137,7 +130,7 @@ def is_reparse(path: Path) -> bool: with patch("core.paths._is_reparse", side_effect=is_reparse): paths = BuildPaths(repository) with self.assertRaisesRegex(PathSafetyError, "reparse point"): - paths.cas(UID, "node") + paths.cas(UID) reparse_paths.remove(regular.cas_root) with self.assertRaisesRegex(PathSafetyError, "reparse point"): diff --git a/build/tests/test_python_coverage_graph.py b/build/tests/test_python_coverage_graph.py index a4fe6f3..15f19b1 100644 --- a/build/tests/test_python_coverage_graph.py +++ b/build/tests/test_python_coverage_graph.py @@ -61,6 +61,17 @@ def test_single_gate_signs_all_first_party_tests_config_lock_and_exact_local_cov (sys.executable, "-m", "core.python_coverage", str(repository / "build/.venv/Scripts/coverage.exe"), str(repository / "build")), ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in node.results), + ( + ("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"), + ("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"), + ("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"), + ("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"), + ("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"), + ), + ) (repository / "build/tests/test_example.py").write_text("changed\n", encoding="utf-8") changed_test = python_coverage_graph(repository) (repository / "build/tests/test_example.py").write_text("pass\n", encoding="utf-8") diff --git a/build/tests/test_recipe.py b/build/tests/test_recipe.py index 3f3b5b9..c0ed051 100644 --- a/build/tests/test_recipe.py +++ b/build/tests/test_recipe.py @@ -9,7 +9,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.graph import GraphError # noqa: E402 +from core.graph import GraphError, Result # noqa: E402 from core.recipe import Recipe, RecipeError # noqa: E402 @@ -18,6 +18,14 @@ def rendered_recipe(**overrides: object) -> str: "name": "analyze-renpy-x64", "pool": "slot", "inputs": ["compile-renpy-x64"], + "results": [ + { + "id": "analysis/renpy/x64/sarif", + "kind": "report", + "media_type": "application/sarif+json", + "path": "analysis/renpy-x64.sarif", + } + ], "script": { "exec": ["pwsh.exe", "-NoProfile", "-Command", "-"], "data": "Write-Output 'привет'\r\n", @@ -36,6 +44,17 @@ def test_repository_recipe_is_parsed_without_reordering_process_data(self) -> No self.assertEqual(recipe.inputs, ("compile-renpy-x64",)) self.assertEqual(recipe.argv, ("pwsh.exe", "-NoProfile", "-Command", "-")) self.assertEqual(recipe.data, "Write-Output 'привет'\r\n".encode()) + self.assertEqual( + recipe.results, + ( + Result( + "analysis/renpy/x64/sarif", + "report", + "application/sarif+json", + "analysis/renpy-x64.sarif", + ), + ), + ) def test_node_bridge_uses_signed_uid_direct_dependencies_and_process_data(self) -> None: recipe = Recipe.parse(rendered_recipe()) @@ -50,6 +69,7 @@ def test_node_bridge_uses_signed_uid_direct_dependencies_and_process_data(self) self.assertEqual(current.command.argv, recipe.argv) self.assertEqual(current.command.stdin, recipe.data) self.assertEqual(current.command.cwd, r"C:\repo\out\work\one") + self.assertEqual(current.results, recipe.results) self.assertEqual( current.command.env, (("OBSERVER_OUT_DIR", r"C:\repo\out"), ("ZED", "last")), @@ -82,6 +102,22 @@ def test_invalid_json_or_missing_required_fields_are_rejected(self) -> None: with self.subTest(script_field=field), self.assertRaises(RecipeError): Recipe.parse(json.dumps({**valid, "script": script})) + malformed_results = ( + {"id": "report", "kind": "report", "media_type": "application/json"}, + {"id": "report", "kind": "report", "media_type": "invalid", "path": "x"}, + "not-a-list", + ) + for results in malformed_results: + with self.subTest(results=results), self.assertRaises(RecipeError): + Recipe.parse(rendered_recipe(results=results)) + + def test_recipe_requires_the_typed_results_field(self) -> None: + document = json.loads(rendered_recipe()) + del document["results"] + + with self.assertRaises(RecipeError): + Recipe.parse(json.dumps(document)) + def test_graph_and_process_descriptors_remain_validation_boundaries(self) -> None: recipe = Recipe.parse(rendered_recipe()) diff --git a/build/tests/test_render.py b/build/tests/test_render.py index 4b5d684..612b76f 100644 --- a/build/tests/test_render.py +++ b/build/tests/test_render.py @@ -28,6 +28,7 @@ def variables(self) -> dict[str, object]: "name": "build-renpy-x64", "pool": "slot", "inputs": ["src/modules/renpy/renpy.vcxproj"], + "results": [], "pwsh": "pwsh.exe", "msbuild": r"C:\Program Files\O'Brien Tools\MSBuild.exe", "project": r"C:\repo\RPG's\renpy.vcxproj", @@ -44,6 +45,7 @@ def test_msbuild_leaf_inherits_complete_json_recipe(self) -> None: self.assertEqual(recipe["name"], "build-renpy-x64") self.assertEqual(recipe["pool"], "slot") self.assertNotIn("outputs", recipe) + self.assertEqual(recipe["results"], []) self.assertEqual( recipe["script"]["exec"], [ @@ -89,6 +91,21 @@ def test_missing_variable_fails_before_a_recipe_is_created(self) -> None: with self.assertRaisesRegex(UndefinedError, "platform.*undefined"): self.renderer.render("msbuild.ps1", variables) + def test_inherited_recipe_renders_declared_typed_results(self) -> None: + variables = self.variables() + variables["results"] = [ + { + "id": "binary/renpy/x64", + "kind": "module", + "media_type": "application/vnd.microsoft.portable-executable", + "path": "bin/renpy.dll", + } + ] + + recipe = json.loads(self.renderer.render("msbuild.ps1", variables)) + + self.assertEqual(recipe["results"], variables["results"]) + def test_power_shell_quote_is_a_single_literal(self) -> None: self.assertEqual(ps_quote("plain"), "'plain'") self.assertEqual(ps_quote("O'Brien"), "'O''Brien'") @@ -103,6 +120,36 @@ def test_msbuild_family_template_stays_reviewable(self) -> None: self.assertLessEqual(len(meaningful_lines), 18) + def test_output_postconditions_share_one_strict_template_macro(self) -> None: + helper = self.templates / "_output.ps1" + leaves = ( + "catch2-test.ps1", + "clang-command.ps1", + "clang-tidy.ps1", + "fuzz-build.ps1", + "msvc-analyze.ps1", + "sanitizer-test.ps1", + "source-dependencies.ps1", + "vcpkg.ps1", + ) + + self.assertTrue(helper.is_file()) + self.assertIn("macro require_output", helper.read_text(encoding="utf-8")) + for name in leaves: + with self.subTest(template=name): + content = (self.templates / name).read_text(encoding="utf-8") + self.assertIn('from "_output.ps1" import require_output', content) + self.assertNotIn("if (-not (Test-Path", content) + + variables = self.variables() | { + "llvm_dir": "llvm", + "source": "unit.cpp", + "vcpkg_installed": "installed", + "vcpkg_root": "vcpkg", + } + with self.assertRaisesRegex(UndefinedError, "project_name.*undefined"): + self.renderer.render("msvc-analyze.ps1", variables) + def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) -> None: base = self.templates / "catch2-test.ps1" self.assertTrue(base.is_file(), "Catch2 shard recipes must share one inherited base") @@ -111,6 +158,7 @@ def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) "name": "fixture", "pool": "slot", "inputs": ["build-tests"], + "results": [], "pwsh": "pwsh.exe", "artifacts": [ {"name": "tests.exe", "source": "C:/cas/tests.exe"}, @@ -122,15 +170,15 @@ def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) cases = { "native-test.ps1": ( variables, - "8e8be23fd290ead07b8a09d6ded0dcf6cd24c56cee6dc8b5f532bb06ad8581d4", + "7c5777763ff4cd93f343b773511bec55c61acc717128c9bfff8d6735bcb96f83", ), "native-corpus-test.ps1": ( variables, - "525b016aaf8d444d342412bc5ac81221587d562f65ad895eba88891a2c80685a", + "85e7f28d606dac5a70d01be851b5e17ed47a650a8287c32163881dbce21a6033", ), "coverage-test.ps1": ( variables, - "65e2c48ac05288cf82e4691e3bd3eb5b745fef71dd2ff6212992a706012868d2", + "2ea01c93809ed7985d11147e0a2a060b945c7b2f6645c5d0c7375c023ecaeacc", ), "sanitizer-test.ps1": ( variables @@ -142,7 +190,7 @@ def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) "options_name": "ASAN_OPTIONS", "options_value": "halt_on_error=1", }, - "6fac2869ac98cb3c3f3fb65f9b1e0856f41a2fabcd3e018f347ddb8e28d1455b", + "261dc649182fe2353fcb0393a3eeb2a70ff9f17879adb654048305d6ae4289f1", ), "sanitizer-test.ps1:ubsan": ( variables @@ -151,7 +199,7 @@ def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) "options_name": "UBSAN_OPTIONS", "options_value": "halt_on_error=1", }, - "c885f92dd881d55c2bd017bed98ded28e409cb2c3db1f386869ecfe789618ca6", + "47ddf49be4a5df8279eb7a6bf5229550da255d3256395493e30fad45d8726800", ), } for case, (values, expected) in cases.items(): diff --git a/build/tests/test_result_export.py b/build/tests/test_result_export.py new file mode 100644 index 0000000..f7fec3b --- /dev/null +++ b/build/tests/test_result_export.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import stat +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import core.result_export as result_export # noqa: E402 +from core.graph import Command, Graph, Node, Result # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from core.result_export import ResultExportError, export_results # noqa: E402 +from core.store import CasStore # noqa: E402 + + +def node(name: str, *results: Result) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "cpu", + Command(("tool",)), + results=results, + ) + + +class ResultExportTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[BuildPaths, CasStore]: + repository = root / "repo" + repository.mkdir() + paths = BuildPaths(repository) + paths.prepare() + return paths, CasStore(paths, "export-test") + + @staticmethod + def prepare(store: CasStore, current: Node, *, complete: bool = True) -> Path: + cas = store.prepare_entry(current) + cas.log.write_text(f"log for {current.name}\n", encoding="utf-8") + if complete: + store.mark_complete(current) + return cas.output + + def test_files_and_directories_publish_by_logical_id_with_manifest_last(self) -> None: + package = Result( + "packages/x64/renpy.zip", "package", "application/zip", "artifacts/renpy.zip" + ) + coverage = Result( + "reports/coverage/x64", "report", "application/json", "coverage" + ) + producer = node("publish", package, coverage) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + (output / "artifacts").mkdir() + (output / "artifacts/renpy.zip").write_bytes(b"archive") + (output / "coverage/sub").mkdir(parents=True) + (output / "coverage/index.json").write_bytes(b"{}") + (output / "coverage/sub/detail.json").write_bytes(b'{"ok":true}') + destination = root / "export" + + write_manifest = result_export._write_manifest + manifest_order: list[Path] = [] + + def observe(staging: Path, document: dict[str, object]) -> None: + manifest_order.append(staging) + root_staging = staging.parent if staging.name == "packages" else staging + self.assertTrue((root_staging / "packages/x64/renpy.zip").is_file()) + self.assertTrue((root_staging / coverage.id / "sub/detail.json").is_file()) + self.assertFalse((root_staging / "manifest.json").exists()) + if staging.name == "packages": + self.assertEqual( + [entry["path"] for entry in document["results"]], + ["x64/renpy.zip"], + ) + else: + self.assertTrue((staging / "packages/manifest.json").is_file()) + self.assertEqual( + [entry["path"] for entry in document["results"]], + [coverage.id], + ) + write_manifest(staging, document) + + with mock.patch.object(result_export, "_write_manifest", side_effect=observe) as write: + published = export_results(graph, store, destination, "verify", "success") + + self.assertEqual(published, destination) + self.assertEqual(write.call_count, 2) + self.assertEqual(manifest_order[0], manifest_order[1] / "packages") + self.assertEqual((destination / "packages/x64/renpy.zip").read_bytes(), b"archive") + manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8")) + package_manifest = json.loads( + (destination / "packages/manifest.json").read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["schema"], 1) + self.assertEqual(manifest["command"], "verify") + self.assertEqual(manifest["status"], "success") + self.assertEqual(manifest["failures"], []) + self.assertEqual(manifest["logs"], []) + self.assertEqual([entry["path"] for entry in manifest["results"]], [coverage.id]) + self.assertEqual(package_manifest["schema"], 1) + self.assertEqual(package_manifest["command"], "verify") + self.assertEqual(package_manifest["status"], "success") + self.assertEqual(package_manifest["failures"], []) + self.assertEqual(package_manifest["logs"], []) + self.assertEqual( + [entry["path"] for entry in package_manifest["results"]], + ["x64/renpy.zip"], + ) + file_entry = package_manifest["results"][0] + self.assertEqual(file_entry["id"], package.id) + directory_entry = manifest["results"][0] + self.assertEqual(file_entry["size"], 7) + self.assertEqual(file_entry["sha256"], hashlib.sha256(b"archive").hexdigest()) + self.assertEqual(file_entry["object_type"], "file") + self.assertEqual(directory_entry["size"], len(b"{}") + len(b'{"ok":true}')) + self.assertEqual(directory_entry["object_type"], "directory") + self.assertRegex(directory_entry["sha256"], r"^[0-9a-f]{64}$") + self.assertNotIn(str(root), json.dumps(manifest)) + self.assertNotIn(str(root), json.dumps(package_manifest)) + + def test_failed_export_includes_existing_logs_and_only_complete_results(self) -> None: + report = Result("reports/ready.json", "report", "application/json", "ready.json") + package = Result( + "packages/x64/ready.zip", "package", "application/zip", "ready.zip" + ) + partial = Result("reports/partial.json", "report", "application/json", "partial.json") + ready, failed, pending = node("ready", report, package), node("failed", partial), node("pending") + graph = Graph( + (ready, failed, pending), + (ready.name, failed.name, pending.name), + {"cpu": 1}, + ) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + ready_output = self.prepare(store, ready) + (ready_output / "ready.json").write_bytes(b"ready") + (ready_output / "ready.zip").write_bytes(b"package") + failed_output = self.prepare(store, failed, complete=False) + (failed_output / "partial.json").write_bytes(b"partial") + + destination = root / "failed-export" + export_results( + graph, + store, + destination, + "verify", + "failed", + failures=(failed.name,), + ) + manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8")) + + self.assertTrue((destination / report.id).is_file()) + self.assertFalse((destination / "packages").exists()) + self.assertFalse((destination / partial.id).exists()) + self.assertEqual((destination / "logs/ready.log").read_text(), "log for ready\n") + self.assertEqual((destination / "logs/failed.log").read_text(), "log for failed\n") + + self.assertEqual(manifest["failures"], [failed.name]) + self.assertEqual([entry["id"] for entry in manifest["results"]], [report.id]) + self.assertEqual( + [entry["path"] for entry in manifest["logs"]], + ["logs/ready.log", "logs/failed.log"], + ) + + def test_existing_destination_missing_result_and_path_collisions_are_rejected(self) -> None: + cases = ( + "existing", + "missing", + "component", + "collision", + "reserved", + "package-reserved", + "packages-root", + ) + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + results = { + "collision": ( + Result("reports", "report", "application/json", "one.json"), + Result("reports/child", "report", "application/json", "two.json"), + ), + "reserved": ( + Result("manifest.json", "report", "application/json", "one.json"), + ), + "package-reserved": ( + Result( + "packages/manifest.json", + "package", + "application/json", + "one.json", + ), + ), + "packages-root": ( + Result("packages", "report", "application/json", "one.json"), + ), + }.get(case, (Result("report", "report", "application/json", "one.json"),)) + producer = node("producer", *results) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + output = self.prepare(store, producer) + if case != "missing": + (output / "one.json").write_bytes(b"one") + if case == "component": + producer = node( + "nested", + Result("nested", "report", "application/json", "one.json/child.json"), + ) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + nested_output = self.prepare(store, producer) + (nested_output / "one.json").write_bytes(b"not a directory") + if case == "collision": + (output / "two.json").write_bytes(b"two") + destination = root / "export" + if case == "existing": + destination.mkdir() + (destination / "sentinel").write_bytes(b"keep") + + with self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", "success") + + if case == "existing": + self.assertEqual((destination / "sentinel").read_bytes(), b"keep") + else: + self.assertFalse(destination.exists()) + + def test_reparse_sources_and_publication_failure_leave_no_partial_destination(self) -> None: + result = Result("reports/result.json", "report", "application/json", "result.json") + package = Result("packages/x64/result.zip", "package", "application/zip", "result.zip") + producer = node("producer", result, package) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + for case in ("reparse", "manifest-failure"): + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + source = output / result.relative_path + source.write_bytes(b"result") + (output / package.relative_path).write_bytes(b"package") + destination = root / "export" + if case == "reparse": + patch = mock.patch.object( + result_export, "_is_reparse", side_effect=lambda path: path == source + ) + else: + write_manifest = result_export._write_manifest + + def fail_root_manifest( + staging: Path, document: dict[str, object] + ) -> None: + if staging.name == "packages": + write_manifest(staging, document) + return + raise OSError("disk full") + + patch = mock.patch.object( + result_export, "_write_manifest", side_effect=fail_root_manifest + ) + + with patch, self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", "success") + + self.assertFalse(destination.exists()) + self.assertEqual([child.name for child in root.iterdir()], ["repo"]) + + def test_unsupported_entries_logs_and_destination_paths_are_rejected(self) -> None: + directory = Result("tree", "report", "application/octet-stream", "tree") + producer = node("producer", directory) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + cases = ("root-special", "child-special", "log-directory", "missing-parent", "dest-reparse") + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + (output / "tree").mkdir() + special = output / "tree/special" + special.write_bytes(b"special") + destination = root / "export" + if case == "root-special": + patch = mock.patch.object( + result_export, + "_source", + return_value=(output / "tree", SimpleNamespace(st_mode=stat.S_IFIFO)), + ) + elif case == "child-special": + checked = result_export._checked + patch = mock.patch.object( + result_export, + "_checked", + side_effect=lambda path: ( + SimpleNamespace(st_mode=stat.S_IFIFO) + if path == special + else checked(path) + ), + ) + elif case == "log-directory": + cas = store.paths_for(producer) + cas.log.unlink() + cas.log.mkdir() + patch = mock.patch.object(store, "is_complete", return_value=False) + elif case == "missing-parent": + destination = root / "missing/export" + patch = mock.patch.object(result_export, "_write_manifest", wraps=result_export._write_manifest) + else: + patch = mock.patch.object( + result_export, "_is_reparse", side_effect=lambda path: path == root + ) + + status = "failed" if case == "log-directory" else "success" + with patch, self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", status) + + self.assertFalse(destination.exists()) + + def test_publication_race_does_not_replace_the_winner(self) -> None: + only = node("only") + graph = Graph((only,), (only.name,), {"cpu": 1}) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + destination = root / "export" + lstat = result_export._lstat + destination_checks = 0 + + def raced(path: Path): + nonlocal destination_checks + if path == destination: + destination_checks += 1 + if destination_checks == 2: + return SimpleNamespace(st_mode=stat.S_IFDIR) + return lstat(path) + + with mock.patch.object(result_export, "_lstat", side_effect=raced): + with self.assertRaisesRegex(ResultExportError, "already exists"): + export_results(graph, store, destination, "verify", "success") + + self.assertFalse(destination.exists()) + + def test_manifest_inputs_are_closed_over_graph_names_and_status(self) -> None: + only = node("only") + graph = Graph((only,), (only.name,), {"cpu": 1}) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + invalid = ( + (42, "success", ()), + (r"C:\absolute\command.exe", "success", ()), + ("verify", "unknown", ()), + ("verify", "success", ("missing",)), + ("verify", "success", (only.name,)), + ("verify", "failed", (only.name, only.name)), + ) + for index, (command, status, failures) in enumerate(invalid): + with self.subTest(command=command, status=status, failures=failures): + with self.assertRaises(ResultExportError): + export_results( + graph, + store, + root / f"export-{index}", + command, + status, + failures=failures, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_sanitizer_graph.py b/build/tests/test_sanitizer_graph.py index 4f0a45e..83fc85b 100644 --- a/build/tests/test_sanitizer_graph.py +++ b/build/tests/test_sanitizer_graph.py @@ -20,7 +20,6 @@ from core.sanitizer import SanitizerError, main as sanitizer_main, require_clean_log # noqa: E402 from graphs.sanitizer import ( # noqa: E402 AsanRuntime, - SanitizerArtifact, sanitizer_artifact_graph, sanitizer_dependency_discovery_slice, sanitizer_graph, @@ -45,13 +44,10 @@ def fixture( selections: tuple[tuple[str, str], ...] = ( ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") ), - ) -> tuple[ - Path, Graph, tuple[SanitizerArtifact, ...], tuple[AsanRuntime, ...], Path - ]: + ) -> tuple[Path, Graph, tuple[AsanRuntime, ...], Path]: repository = root / "repo" repository.mkdir() - paths = BuildPaths(repository) - nodes, artifacts = [], [] + nodes = [] for sanitizer, architecture in selections: restore_name = ( f"restore-vcpkg-asan-{architecture}" @@ -74,15 +70,6 @@ def fixture( inputs=(discovery.name,), ) nodes.append(producer) - artifacts.append( - SanitizerArtifact( - sanitizer, - architecture, - name, - producer, - paths.cas(producer.uid, producer.name).output / filename, - ) - ) upstream = Graph( tuple(nodes), tuple(current.name for current in nodes[1:]), {"build": 8} ) @@ -101,19 +88,19 @@ def fixture( runtimes.append( AsanRuntime(architecture, path, {"sha256": f"runtime-{architecture}"}) ) - return repository, upstream, tuple(artifacts), tuple(runtimes), pwsh + return repository, upstream, tuple(runtimes), pwsh def build(self, root: Path, **options: object) -> Graph: selections = options.pop( "selections", (("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")) ) - repository, upstream, artifacts, runtimes, pwsh = self.fixture( + repository, upstream, runtimes, pwsh = self.fixture( root, selections # type: ignore[arg-type] ) return sanitizer_artifact_graph( repository, upstream, - artifacts, + selections=selections, # type: ignore[arg-type] pwsh=pwsh, pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, asan_runtimes=runtimes, @@ -156,9 +143,18 @@ def test_asan_and_ubsan_build_adapters_create_independent_shard_gates(self) -> N gate.command.argv[1:], ( "-m", "core.sanitizer", "gate", sanitizer, - str(paths.cas(shard.uid, shard.name).log), + str(paths.cas(shard.uid).log), ), ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in shard.results), + (( + f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml", + "sanitizer", "application/xml", "tests.xml", + ),), + ) + self.assertEqual(gate.results, ()) script = shard.command.stdin.decode("utf-8") self.assertIn("Invoke-Checked", script) self.assertIn("'--shard-count'", script) @@ -180,7 +176,7 @@ def test_asan_and_ubsan_build_adapters_create_independent_shard_gates(self) -> N def test_runtime_and_pwsh_identities_have_narrow_invalidation_partitions(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, runtimes, pwsh = self.fixture(root) + repository, upstream, runtimes, pwsh = self.fixture(root) def build( pwsh_version: str = "7.5", runtime_x64: str = "runtime-x64" @@ -196,7 +192,7 @@ def build( return sanitizer_artifact_graph( repository, upstream, - artifacts, + selections=(("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")), pwsh=pwsh, pwsh_identity={"version": pwsh_version}, asan_runtimes=changed, @@ -218,17 +214,17 @@ def build( ) self.assertNotEqual(current.uid, pwsh_changed.node(current.name).uid, current.name) - def test_adapter_rejects_invalid_sets_outputs_restore_edges_tools_and_pools(self) -> None: + def test_rejects_invalid_selections_lineage_runtimes_tools_and_pools(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository, upstream, artifacts, runtimes, pwsh = self.fixture( + repository, upstream, runtimes, pwsh = self.fixture( root, (("asan", "x64"),) ) def invoke( - selected: tuple[SanitizerArtifact, ...] = artifacts, *, source: Graph = upstream, + selections: tuple[tuple[str, str], ...] = (("asan", "x64"),), selected_runtimes: tuple[AsanRuntime, ...] = runtimes, pwsh_path: Path = pwsh, **options: object, @@ -236,51 +232,30 @@ def invoke( return sanitizer_artifact_graph( repository, source, - selected, + selections=selections, pwsh=pwsh_path, pwsh_identity={"version": "7.5"}, asan_runtimes=selected_runtimes, **options, ) - with self.assertRaisesRegex(ValueError, "at least one"): - invoke(()) - with self.assertRaisesRegex(ValueError, "complete"): - invoke(artifacts[:-1]) - with self.assertRaisesRegex(ValueError, "duplicate"): - invoke(artifacts + (artifacts[0],)) for invalid in ( - SanitizerArtifact("msan", "x64", "renpy", artifacts[0].producer, artifacts[0].path), - SanitizerArtifact("asan", "arm64", "renpy", artifacts[0].producer, artifacts[0].path), - SanitizerArtifact("ubsan", "x86", "renpy", artifacts[0].producer, artifacts[0].path), - SanitizerArtifact("asan", "x64", "bad", artifacts[0].producer, artifacts[0].path), + (), + (("asan", "x64"), ("asan", "x64")), + (("msan", "x64"),), + (("asan", "arm64"),), + (("ubsan", "x86"),), ): - with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "identity"): - invoke((invalid,)) + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "selections"): + invoke(selections=invalid) - wrong_path = SanitizerArtifact( - "asan", "x64", "renpy", artifacts[0].producer, root / "outside.so" - ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((wrong_path, *artifacts[1:])) - wrong_name = SanitizerArtifact( - "asan", "x64", "renpy", artifacts[0].producer, - artifacts[0].path.with_name("wrong.so"), - ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((wrong_name, *artifacts[1:])) - wrong_producer = SanitizerArtifact( - "asan", "x64", "renpy", artifacts[1].producer, artifacts[1].path - ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((wrong_producer, *artifacts[1:])) - impostor = Node( - artifacts[0].producer.name, - hashlib.md5(b"impostor", usedforsecurity=False).hexdigest(), - "build", artifacts[0].producer.command, artifacts[0].producer.inputs, + missing = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-asan"), + tuple(name for name in upstream.targets if name != "build-tests-x64-asan"), + upstream.pools, ) - with self.assertRaisesRegex(ValueError, "producer CAS"): - invoke((SanitizerArtifact("asan", "x64", "renpy", impostor, artifacts[0].path), *artifacts[1:])) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=missing) shared = node("detached-shared") left = node("detached-left", inputs=(shared.name,)) @@ -296,12 +271,8 @@ def invoke( upstream.targets, upstream.pools, ) - detached_artifact = SanitizerArtifact( - "asan", "x64", "renpy", detached, - BuildPaths(repository).cas(detached.uid, detached.name).output / "renpy.so", - ) with self.assertRaisesRegex(ValueError, "restore ancestor"): - invoke((detached_artifact, *artifacts[1:]), source=detached_source) + invoke(source=detached_source) with self.assertRaisesRegex(ValueError, "runtime.*required"): invoke(selected_runtimes=()) diff --git a/build/tests/test_source_graph.py b/build/tests/test_source_graph.py index 85ae48d..9778825 100644 --- a/build/tests/test_source_graph.py +++ b/build/tests/test_source_graph.py @@ -12,7 +12,13 @@ REPOSITORY = BUILD_ROOT.parent sys.path.insert(0, str(BUILD_ROOT)) -from graphs.source import SourceTools, _repository_files, source_checks # noqa: E402 +from graphs.source import ( # noqa: E402 + SourceTools, + _repository_files, + architecture_source_checks, + common_source_checks, + source_checks, +) from core.paths import BuildPaths # noqa: E402 @@ -68,7 +74,11 @@ def test_every_independent_source_check_is_a_demand_node(self) -> None: powershell_sources = [REPOSITORY / "build.ps1"] + sorted( path for path in (REPOSITORY / "build").rglob("*") - if path.is_file() and path.suffix in {".ps1", ".psm1"} + if ( + path.is_file() + and path.suffix in {".ps1", ".psm1"} + and not path.is_relative_to(REPOSITORY / "build/templates") + ) ) contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) @@ -86,6 +96,7 @@ def test_every_independent_source_check_is_a_demand_node(self) -> None: len([node for node in graph.nodes if node.name.startswith("pssa-")]), len(powershell_sources), ) + self.assertFalse(any(node.name.startswith("pssa-build.templates.") for node in graph.nodes)) self.assertEqual( len([node for node in graph.nodes if node.name.startswith("contract-")]), len(contracts), @@ -143,6 +154,41 @@ def test_cppcheck_matrix_contains_only_requested_supported_architectures(self) - with self.subTest(architectures=architectures), self.assertRaisesRegex(ValueError, "architectures"): source_checks(REPOSITORY, self.tools(), architectures=architectures) + def test_ci_slices_do_not_duplicate_common_and_architecture_checks(self) -> None: + common = common_source_checks(REPOSITORY, self.tools(), jobs=7) + architecture = architecture_source_checks( + REPOSITORY, self.tools(), ("arm64",), jobs=7 + ) + + self.assertEqual(common.targets, ("source-checks",)) + self.assertFalse(any(node.name.startswith("cppcheck-") for node in common.nodes)) + self.assertFalse(any(node.name.startswith("restore-vcpkg-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("format-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("pssa-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("contract-") for node in common.nodes)) + + self.assertEqual(architecture.targets, ("cppcheck-checks-arm64",)) + self.assertEqual( + {node.name for node in architecture.nodes}, + { + "restore-vcpkg-arm64", + "cppcheck-arm64", + "merge-cppcheck-findings-arm64", + "cppcheck-checks-arm64", + }, + ) + self.assertEqual(dict(architecture.pools), {"restore": 1, "slot": 7}) + common_producer, common_result = common.result( + "reports/sarif/source/analysis.sarif" + ) + self.assertEqual(common_producer.name, "merge-source-findings") + self.assertEqual(common_result.relative_path, "analysis.sarif") + cppcheck_producer, cppcheck_result = architecture.result( + "reports/sarif/arm64/cppcheck.sarif" + ) + self.assertEqual(cppcheck_producer.name, "merge-cppcheck-findings-arm64") + self.assertEqual(cppcheck_result.relative_path, "analysis.sarif") + def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(self) -> None: graph = self.graph() @@ -158,14 +204,19 @@ def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(sel cppcheck_script = cppcheck.command.stdin.decode() restore = graph.node("restore-vcpkg-x86") include_dir = ( - BuildPaths(REPOSITORY).cas(restore.uid, restore.name).output + BuildPaths(REPOSITORY).cas(restore.uid).output / "observer-x86-windows-static/include" ) self.assertIn("Invoke-Checked 'C:\\tools\\cppcheck.exe'", cppcheck_script) self.assertIn("'--platform=win32W'", cppcheck_script) self.assertIn("'-D_M_IX86=600'", cppcheck_script) self.assertIn(f"'-I{include_dir}'", cppcheck_script) - self.assertIn("'--suppress=*:out/cas/*-restore-vcpkg-*/out/*'", cppcheck_script) + self.assertNotIn(f"--suppress=*:{include_dir}", cppcheck_script) + self.assertIn( + "'--suppress=*:*\\observer-x86-windows-static\\include\\*'", + cppcheck_script, + ) + self.assertNotIn("*-restore-vcpkg-*", cppcheck_script) self.assertIn('"--output-file=$outDir\\cppcheck.sarif"', cppcheck_script) self.assertNotIn("--error-exitcode", cppcheck_script) self.assertIn("Cppcheck did not produce cppcheck.sarif", cppcheck_script) diff --git a/build/tests/test_store.py b/build/tests/test_store.py index 9dde552..033b592 100644 --- a/build/tests/test_store.py +++ b/build/tests/test_store.py @@ -20,10 +20,10 @@ RUN_ID = "20260801-test" -def node(name: str = "analyze-renpy.pickle") -> Node: +def node(name: str = "analyze-renpy.pickle", uid: str | None = None) -> Node: return Node( name=name, - uid=hashlib.md5(name.encode("utf-8"), usedforsecurity=False).hexdigest(), + uid=uid or hashlib.md5(name.encode("utf-8"), usedforsecurity=False).hexdigest(), pool="cpu", command=Command(("tool",)), ) @@ -37,13 +37,13 @@ def make_store(self, repository: Path) -> tuple[BuildPaths, CasStore]: @staticmethod def publish_files(paths: BuildPaths, current: Node) -> None: - cas = paths.cas(current.uid, current.name) + cas = paths.cas(current.uid) cas.entry.mkdir() cas.output.mkdir() cas.log.write_text("command succeeded\n", encoding="utf-8") cas.touch.touch() - def test_node_maps_to_readable_uid_and_name_entry(self) -> None: + def test_node_maps_to_uid_only_entry(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" repository.mkdir() @@ -54,9 +54,22 @@ def test_node_maps_to_readable_uid_and_name_entry(self) -> None: self.assertEqual( cas.entry, - paths.cas_root / f"{current.uid}-{current.name}", + paths.cas_root / current.uid, ) + def test_readable_name_collision_shares_content_addressed_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + _paths, store = self.make_store(repository) + shared_uid = "0123456789abcdef0123456789abcdef" + + first = store.paths_for(node("first-node", shared_uid)) + second = store.paths_for(node("second-node", shared_uid)) + + self.assertEqual(first, second) + self.assertEqual(first.entry.name, shared_uid) + def test_complete_entry_is_a_warm_cache_hit(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" @@ -84,7 +97,7 @@ def test_missing_or_malformed_marker_and_missing_outputs_are_cache_misses(self) paths, store = self.make_store(repository) current = node() self.publish_files(paths, current) - cas = paths.cas(current.uid, current.name) + cas = paths.cas(current.uid) if case == "missing-touch": cas.touch.unlink() @@ -112,7 +125,7 @@ def test_prepare_quarantines_incomplete_entry_inside_current_run(self) -> None: repository.mkdir() paths, store = self.make_store(repository) current = node() - old = paths.cas(current.uid, current.name) + old = paths.cas(current.uid) old.entry.mkdir() old.output.mkdir() (old.output / "partial.obj").write_bytes(b"partial") @@ -120,7 +133,7 @@ def test_prepare_quarantines_incomplete_entry_inside_current_run(self) -> None: prepared = store.prepare_entry(current) quarantine = paths.run_work(RUN_ID) / "quarantine" / old.entry.name - self.assertEqual(prepared, paths.cas(current.uid, current.name)) + self.assertEqual(prepared, paths.cas(current.uid)) self.assertEqual((quarantine / "out" / "partial.obj").read_bytes(), b"partial") self.assertTrue(prepared.entry.is_dir()) self.assertTrue(prepared.output.is_dir()) @@ -137,7 +150,7 @@ def test_prepare_resolves_node_paths_once(self) -> None: with mock.patch.object(paths, "cas", wraps=paths.cas) as resolve: store.prepare_entry(current) - resolve.assert_called_once_with(current.uid, current.name) + resolve.assert_called_once_with(current.uid) def test_prepare_never_mutates_complete_entry(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -146,7 +159,7 @@ def test_prepare_never_mutates_complete_entry(self) -> None: paths, store = self.make_store(repository) current = node() self.publish_files(paths, current) - cas = paths.cas(current.uid, current.name) + cas = paths.cas(current.uid) sentinel = cas.output / "result.bin" sentinel.write_bytes(b"immutable") before = { @@ -170,7 +183,7 @@ def test_prepare_fails_if_incomplete_entry_cannot_be_quarantined(self) -> None: repository.mkdir() paths, store = self.make_store(repository) current = node() - cas = paths.cas(current.uid, current.name) + cas = paths.cas(current.uid) cas.entry.mkdir() cas.output.mkdir() destination = paths.run_work(RUN_ID) / "quarantine" / cas.entry.name @@ -188,7 +201,7 @@ def test_prepare_reports_move_failure_without_deleting_or_retrying(self) -> None repository.mkdir() paths, store = self.make_store(repository) current = node() - cas = paths.cas(current.uid, current.name) + cas = paths.cas(current.uid) cas.entry.mkdir() cas.output.mkdir() @@ -210,7 +223,7 @@ def test_reparse_component_is_rejected_instead_of_followed(self) -> None: regular.prepare() current = node() self.publish_files(regular, current) - cas = regular.cas(current.uid, current.name) + cas = regular.cas(current.uid) reparse_paths = {cas.output} diff --git a/build/tests/test_vcpkg_template.py b/build/tests/test_vcpkg_template.py index 850f1be..ab11f16 100644 --- a/build/tests/test_vcpkg_template.py +++ b/build/tests/test_vcpkg_template.py @@ -21,6 +21,7 @@ def setUp(self) -> None: "name": "restore-vcpkg-x64", "pool": "slot", "inputs": [], + "results": [], "pwsh": "pwsh.exe", "vcpkg": r"C:\tools\vcpkg.exe", "repository": r"C:\repo with O'Brien", @@ -44,13 +45,13 @@ def test_manifest_restore_targets_node_output_and_requires_include_directory(sel ) self.assertIn("throw 'vcpkg restore did not produce the include directory'", script) - def test_mutable_vcpkg_scratch_is_confined_to_the_node_build_directory(self) -> None: + def test_build_scratch_is_confined_while_vcpkg_manages_shared_downloads(self) -> None: recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables)) script = recipe["script"]["data"] self.assertIn('\n "--x-buildtrees-root=$buildDir\\b"\n', script) self.assertIn('\n "--x-packages-root=$buildDir\\p"\n', script) - self.assertIn('\n "--downloads-root=$buildDir\\d"\n', script) + self.assertNotIn("--downloads-root", script) self.assertIn('\n "--x-install-root=$outDir"\n', script) def test_triplet_is_required(self) -> None: diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index 7ba98bc..2943e7c 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import re import unittest @@ -8,24 +9,70 @@ class WorkflowContractTests(unittest.TestCase): - def test_main_ci_is_only_a_thin_public_verify_client(self) -> None: + def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: workflow = (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8") - self.assertEqual(workflow.count("runs-on:"), 1) - self.assertEqual(workflow.count("./build.ps1 doctor"), 1) - self.assertEqual(workflow.count("./build.ps1 verify"), 1) - self.assertIn("./build.ps1 verify -Arch x64", workflow) + self.assertIn("pull_request:", workflow) + self.assertIn("push:", workflow) + self.assertGreaterEqual(workflow.count("branches: [master]"), 2) + self.assertNotIn("workflow_dispatch", workflow) + self.assertNotIn("schedule:", workflow) + self.assertNotIn("pull_request_target", workflow) + self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) + + self.assertIn("runs-on: windows-2022", workflow) + self.assertIn("fail-fast: false", workflow) + matrix_rows = re.findall(r"- job: ([^\n]+)\n\s+arch: ([^\n]+)", workflow) + self.assertEqual( + matrix_rows, + [("source", "none"), ("x86", "x86"), ("x64", "x64"), ("arm64-cross", "arm64")], + ) + self.assertEqual(workflow.count("./build.ps1 verify-source"), 1) + self.assertEqual(workflow.count("./build.ps1 verify-arch"), 1) + self.assertNotIn("./build.ps1 verify -Arch", workflow) self.assertNotIn("continue-on-error", workflow) - for private_protocol in ( - "upload-artifact", - "download-artifact", - "upload-sarif", - "test-reporter", - "codeql-action", - "action-gh-release", - "contents: write", - ): - self.assertNotIn(private_protocol, workflow) + + self.assertIn("upload-artifact", workflow) + self.assertIn("if: always()", workflow) + self.assertIn("/manifest.json", workflow) + self.assertIn("/reports", workflow) + self.assertIn("/logs", workflow) + self.assertNotIn("!${{ runner.temp }}", workflow) + self.assertIn("if-no-files-found: error", workflow) + self.assertIn( + "if: success() && github.event_name == 'push' && matrix.job != 'source'", + workflow, + ) + self.assertIn("/packages", workflow) + self.assertNotIn("if-no-files-found: ignore", workflow) + self.assertIn("permissions:\n contents: read", workflow) + self.assertNotIn("out/cas", workflow) + self.assertNotIn("out/work", workflow) + self.assertNotIn("contents: write", workflow) + self.assertNotIn("download-artifact", workflow) + self.assertNotIn("upload-sarif", workflow) + self.assertNotIn("codeql-action", workflow) + + actions = dict(re.findall(r"uses:\s+([^@\s]+)@([^\s#]+)", workflow)) + self.assertEqual( + actions, + { + "actions/checkout": "d23441a48e516b6c34aea4fa41551a30e30af803", + "astral-sh/setup-uv": "08807647e7069bb48b6ef5acd8ec9567f424441b", + "actions/cache": "caa296126883cff596d87d8935842f9db880ef25", + "actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + }, + ) + for action, revision in actions.items(): + with self.subTest(action=action): + self.assertRegex(revision, r"^[0-9a-f]{40}$") + self.assertIn("persist-credentials: false", workflow) + self.assertIn('version: "0.12.1"', workflow) + self.assertIn("id: runner-image", workflow) + self.assertIn("${{ steps.runner-image.outputs.identity }}", workflow) + self.assertIn("C:\\Program Files\\Cppcheck", workflow) + self.assertIn("$env:GITHUB_PATH", workflow) + for duplicated_gate in ( "./build.ps1 source-checks", "./build.ps1 compiler-analysis", diff --git a/docs/build-system.md b/docs/build-system.md index 08afdfc..37db274 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -5,6 +5,9 @@ This document describes the implemented local build. The repository uses a Python/Jinja content-addressed DAG for orchestration and keeps MSBuild as the native compile/link backend. +The IX-aligned build model and automatic CI sections below record implemented contracts. A feature described as +future or deferred is not part of the current gate. + ## Non-negotiable release contract - Windows-only native C++23, compiled with the MSVC `cl.exe` toolchain. @@ -54,6 +57,65 @@ confined below the exact repository output root and existing reparse points are observes processes; a Windows Job Object terminates the complete descendant tree on failure or cancellation. These are explicit local-build guarantees, not a claim of hostile-process isolation. +## IX-aligned build model + +The goal is not to import IX's package manager. It is to keep the small, proven part that this repository needs: short +inherited recipes, a dependency DAG, demand execution, content identities, and immutable successful outputs. The +native compiler backend remains MSBuild; replacing project evaluation, C++ dependency handling, compilation, and +linking is explicitly outside this stage. + +### Recipe model + +- One rendered recipe describes one cacheable node: readable name, direct dependencies, `in_dir`, `out_dir`, data, + one logical pool, and either an argv command or a shell body. +- Jinja inheritance owns command construction and repeated tool policy. The intended hierarchy is a small JSON base, + an argv or PowerShell base, an MSBuild base where relevant, and a short leaf containing only operation-specific data. + `StrictUndefined` remains mandatory. +- Graph-building Python enumerates targets and real dependency edges and supplies semantic data. It must stop rebuilding + the same argv, environment, path, and script boilerplate in every graph family. +- The rendered artifact remains a JSON/argv descriptor. PowerShell is one recipe backend, not the graph language. A + future local WSL2 implementation can add a POSIX-shell base without changing the node, DAG, or CAS model. +- Typed `results` become part of the recipe contract. Each result has a stable logical id, kind, media type, and a path + relative to the node output; graph code and CI must not infer results by scanning directories. + +### Identity and storage + +- Keep canonical MD5 and the zero-byte `touch` publication marker. MD5 identifies local content; any imported or + downloaded artifact must be authenticated separately. +- The UID covers the rendered recipe, declared input paths and bytes, dependency UIDs, semantic configuration, and a + normalized toolchain fingerprint. +- Absolute checkout/CAS/export paths, `GITHUB_*`, commit and PR ids, run timestamps, log destinations, `Jobs`, pool + capacities, and scheduler order do not affect the UID. +- Change the physical successful-entry key to `out/cas/`. The readable node name remains in graph diagnostics, + logs, and exported manifests instead of being duplicated in the CAS directory name. +- Keep mutable scratch, locks, leases, failed work, and incomplete-entry quarantine below `out/work`. There is no + permanent IX-style trash directory: quarantine is recoverable during the run and stale work is removed by the + existing safe cleanup contract. + +### Execution + +- Every runnable node consumes one shared `Jobs` slot. A recipe declares at most one additional logical pool, but a + narrower pool is introduced only for a measured resource limit or a demonstrated tool serialization requirement. + UMDH and BinSkim have no speculative `2` and `1` caps; by default they use the shared budget like other nodes. +- Preserve all real fine-grained edges and shards: individual translation units/analyzers, test shards, fuzz targets, + leak scenarios and diffs, binary audits, and packages may overlap whenever their inputs are ready. +- The executor uses keep-going semantics. A failed node blocks only its descendants; independent ready work continues, + all failures are collected, successfully produced diagnostics remain publishable, and the command finally exits + nonzero. +- Continue using `filelock`, `psutil`, and the Windows Job Object rather than maintaining substitutes. Prefer a small, + well-maintained open-source dependency whenever it removes repository code without weakening the contract. + +### Public result boundary + +`-ExportDir` on verification and packaging commands copies only declared typed results to stable paths such +as `reports/sarif/x64/...`, `reports/coverage/x64/...`, and `packages/x64/...`; it never exposes the internal CAS +layout. The root `manifest.json` describes the self-contained evidence bundle. Successful package exports have a +separate `packages/manifest.json`, whose paths are relative to that bundle, so CI can publish evidence after failure +without publishing release ZIPs from pull requests. Manifests are written after their files and record command status, +result ids, relative paths, producer UIDs, sizes, and SHA-256 digests. Diagnostics produced before a later gate failure +are exported; release packages are exported only after their complete package gates pass. The export destination is +not part of recipe identity. + ## Supported commands ```powershell @@ -70,6 +132,8 @@ explicit local-build guarantees, not a claim of hostile-process isolation. .\build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds 60 .\build.ps1 audit-binaries -Arch x86,x64,arm64 .\build.ps1 package -Arch x86,x64,arm64 +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir .\build.ps1 verify -Arch x64 .\build.ps1 clean -CleanMode stale-work ``` @@ -79,10 +143,8 @@ PowerShell launcher. Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every format target. -The implementation is derived from IX's small recipe/DAG/CAS model. A node identity is canonical MD5 over its rendered -recipe, declared inputs, toolchain/configuration data, and dependency identities. A hit requires the immutable CAS -entry and its `touch` marker. Results are printed as exact paths below `out/cas`; mutable intermediates and locks live -below `out/work`. +Without `-ExportDir`, commands may print their internal target paths for local diagnostics. Stable consumers use the +typed export boundary; mutable intermediates and locks remain below `out/work`. `verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, @@ -93,8 +155,8 @@ The verify fuzz work covers all four format targets; `-FuzzSeconds` controls eac All graph families are merged into one executor. Ready nodes from builds, tests, analyzers, coverage, sanitizers, fuzzing, leak checks, audit, and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the -shared global capacity; narrower named pools additionally protect tools such as UMDH and BinSkim without adding fake -phase-wide edges. +shared global capacity. The implementation has no unmeasured UMDH or BinSkim limits encoded as +scheduler policy. ## Configurations @@ -120,8 +182,8 @@ integration is not required. Separate install roots keep manifest-mode vcpkg fro switching targets. Normal public commands include the exact restore nodes they require. Independent architecture/flavor restores may run -concurrently; ARM64 ASan is omitted because that configuration is unsupported. `-SkipDependencyRestore` remains a -deprecated compatibility no-op and is rejected on `restore` itself. +concurrently; ARM64 ASan is omitted because that configuration is unsupported. There is no restore-skipping switch: +restore is an ordinary content-addressed graph node and a valid hit is already a no-op. Developer tools are not library dependencies and are discovered by `doctor`: @@ -169,22 +231,79 @@ BinSkim emits one release-binary SARIF file per architecture. Reports remain sep are stored as local CAS evidence. A CI job may repeat these commands, but it must not be the only way to execute or inspect any mandatory gate. -The main GitHub workflow is therefore a thin client: it provisions an otherwise empty hosted runner, then invokes the -same public `doctor` and bounded `verify` commands used locally. It contains no private gate graph, report parser, -artifact-path protocol, or release logic. +The main GitHub workflow remains a thin client: it provisions an otherwise empty hosted runner, invokes public local +commands, and transports their declared results. It contains no private gate graph, report parser, CAS-path protocol, +or release logic. + +## Automatic CI + +CI contains no CI-only quality gate. Everything mandatory in GitHub must remain runnable from an ordinary local +console through the same public entry points: + +```powershell +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x86 -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir +.\build.ps1 verify-arch -Arch arm64 -ExportDir +.\build.ps1 verify -Arch all -ExportDir +``` + +`verify-source` runs architecture-independent formatting, PowerShell analysis, Python tests/100% coverage, and +repository contracts exactly once. `verify-arch` runs the complete applicable graph for one architecture. The existing +`verify -Arch all` remains the local aggregate and composes source plus every architecture into one executor for +maximum local overlap; the split entry points do not define different gates. + +Only two automatic triggers are allowed: + +```yaml +on: + pull_request: + branches: [master] + push: + branches: [master] +``` + +There is no `workflow_dispatch`, scheduled workflow, or ARM64-native runner. The four required jobs are: + +| Job | Public command | Contract | +|---|---|---| +| `source` | `verify-source` | architecture-independent gates | +| `x86` | `verify-arch -Arch x86` | Debug/Release, runnable tests, analysis, ASan, audit, package validation | +| `x64` | `verify-arch -Arch x64` | x86-class gates plus coverage, UBSan, fuzzing, UMDH leaks, and package smoke | +| `arm64-cross` | `verify-arch -Arch arm64` | MSVC cross-build, analysis, binary audit, and package validation | + +ARM64 runtime tests are explicitly deferred; a successful cross job must not report them as executed. All four jobs +are required for pull requests and pushes to `master`. GitHub matrix/job fail-fast is disabled, and the graph's own +keep-going behavior preserves independent evidence inside each job. Superseded pull-request runs are cancelled; +`master` runs are never cancelled by a newer push. + +Pull requests use the same gates and thresholds with shorter explicit bounded-work parameters, for example a small +`-FuzzSeconds` value and minimal leak warm-up/iterations. Pushes to `master` use the full local defaults. There is no +hidden `pr`/`full` gate composition and no manual profile; changing a bounded duration never removes a target or +weakens the 100% coverage and zero-finding gates. + +The workflow may cache only dependency transport data: uv downloads/environment data and the vcpkg binary cache keyed +by pinned manifests, triplets, and toolchain identity. It does not cache `out/cas`, `out/work`, completed packages, or +an installed vcpkg tree. A cold cache may make a run slower but can never change its gates. + +Each job uploads its `-ExportDir` with `if: always()` so reports from completed independent branches survive a later +failure. Pull-request evidence is retained for seven days; `master` evidence for thirty days. Release ZIPs and PDBs are +uploaded only from successful `master` jobs. Actions are pinned to full commit SHAs, permissions default to +`contents: read`, untrusted pull requests receive no secrets, and `pull_request_target` is forbidden. + +The workflow first runs `doctor`, then exactly one public verification command. It always uploads the self-contained +evidence bundle and uploads the separate package bundle only from successful `master` architecture jobs. Branch +protection requires `source`, `x86`, `x64`, and `arm64-cross`. ### Future analysis backlog The following tools are deliberately recorded for later work so that they are not lost while the build and test architecture is being stabilized: -- **Coverity Scan:** add an independent Windows x64 deep-analysis pass after the repository is eligible and registered - with the service. Run it on a manual or scheduled cadence rather than as a pull-request gate because submissions are - external and rate-limited. Keep its project token in CI secrets and treat findings as an additional engine alongside - CodeQL, not as a replacement for the blocking local analyzers. -- **Infer:** evaluate it only after the portable parser-core boundary can be compiled with Clang on Linux. Start with a - non-blocking parser-core job and publish its SARIF output; do not add a second build path for the Windows DLL adapters - merely to accommodate Infer. +- **Coverity Scan:** evaluate it only if it can be integrated into the locally runnable verification contract. It is + not part of the approved CI workflow while submissions require a separate external/manual path. +- **Infer:** evaluate it locally under the future WSL2 parser-core workflow. Do not add a separate CI-only build path + for the Windows DLL adapters merely to accommodate Infer. - **Include What You Use:** introduce it after parser/header separation has stabilized. Pin an IWYU release compatible with the selected LLVM version, generate its compile commands from the canonical build graph, and review suggestions rather than applying fixes automatically. It checks direct/minimal include ownership, not runtime correctness. diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md index 4b10208..ecc40cc 100644 --- a/docs/critical-software-methodology.md +++ b/docs/critical-software-methodology.md @@ -107,7 +107,8 @@ Every layer finds a different defect class; passing one does not substitute for 1. **Fast deterministic tests:** parser/unit tests, common archive-operation tests, and ABI contract tests. 2. **Structural coverage:** 100% LLVM line and branch coverage over first-party production code, plus review of tests that reach each branch. -3. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on x86, x64, and ARM64 where the runner is native. +3. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on runnable x86 and x64 targets; ARM64 is + cross-built, analyzed, audited, and packaged, with unavailable runtime checks reported explicitly as deferred. 4. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis. Diagnostics are fixed or narrowly justified, never globally muted. Additional services such as CodeQL may repeat or extend this evidence but cannot replace a local gate. From 4a8904621aa553073868ac10257459607079cc99 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 17:28:38 +1000 Subject: [PATCH 08/17] Fix BinSkim provisioning in hosted CI --- .github/workflows/main.yml | 15 ++++++++++++--- build/tests/test_workflow_contract.py | 11 ++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ebff77e..b4e0440 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -81,8 +81,15 @@ jobs: } (Split-Path -Parent $cppcheck) | Add-Content -Path $env:GITHUB_PATH Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force - dotnet tool install --global Microsoft.CodeAnalysis.BinSkim --version 4.4.9.11 - (Join-Path $env:USERPROFILE '.dotnet\tools') | Add-Content -Path $env:GITHUB_PATH + $binskimRoot = Join-Path $env:RUNNER_TEMP 'binskim' + nuget install Microsoft.CodeAnalysis.BinSkim -Version 4.4.9.11 ` + -OutputDirectory $binskimRoot -DirectDownload -NonInteractive + $binskim = Join-Path $binskimRoot ` + 'Microsoft.CodeAnalysis.BinSkim.4.4.9.11\tools\net9.0\win-x64\BinSkim.exe' + if (-not (Test-Path -LiteralPath $binskim -PathType Leaf)) { + throw "BinSkim was not found after installation: $binskim" + } + (Split-Path -Parent $binskim) | Add-Content -Path $env:GITHUB_PATH $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) $umdh = Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64\umdh.exe' @@ -110,6 +117,7 @@ jobs: run: ./build.ps1 doctor - name: Verify repository sources + id: verify-source if: matrix.job == 'source' shell: pwsh run: | @@ -117,6 +125,7 @@ jobs: ./build.ps1 verify-source -ExportDir $evidence - name: Verify one architecture + id: verify-arch if: matrix.job != 'source' shell: pwsh run: | @@ -129,7 +138,7 @@ jobs: -LeakIterations $leakIterations -LeakWindows 3 - name: Upload verification evidence - if: always() + if: always() && (steps.verify-source.outcome != 'skipped' || steps.verify-arch.outcome != 'skipped') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: evidence-${{ matrix.job }} diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index 2943e7c..c2cf292 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -33,7 +33,13 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertNotIn("continue-on-error", workflow) self.assertIn("upload-artifact", workflow) - self.assertIn("if: always()", workflow) + self.assertIn("id: verify-source", workflow) + self.assertIn("id: verify-arch", workflow) + self.assertIn( + "if: always() && (steps.verify-source.outcome != 'skipped' || " + "steps.verify-arch.outcome != 'skipped')", + workflow, + ) self.assertIn("/manifest.json", workflow) self.assertIn("/reports", workflow) self.assertIn("/logs", workflow) @@ -71,6 +77,9 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertIn("id: runner-image", workflow) self.assertIn("${{ steps.runner-image.outputs.identity }}", workflow) self.assertIn("C:\\Program Files\\Cppcheck", workflow) + self.assertIn("nuget install Microsoft.CodeAnalysis.BinSkim", workflow) + self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow) + self.assertNotIn("dotnet tool install --global Microsoft.CodeAnalysis.BinSkim", workflow) self.assertIn("$env:GITHUB_PATH", workflow) for duplicated_gate in ( From 7be2c30231dbe5c849e73ebd45ec10075b29356f Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 17:36:01 +1000 Subject: [PATCH 09/17] Fix hosted runner path and vcpkg setup --- .github/workflows/main.yml | 13 +++++++++++++ build/tests/test_workflow_contract.py | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b4e0440..aabc69b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,7 +68,20 @@ jobs: - name: Prepare the pinned Python environment shell: pwsh run: | + "TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV + "TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + $baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | ConvertFrom-Json).'builtin-baseline' + if ($baseline -notmatch '^[0-9a-f]{40}$') { + throw "Invalid vcpkg builtin baseline: $baseline" + } + git -C $env:VCPKG_ROOT cat-file -e "$baseline^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { + git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline + if ($LASTEXITCODE -ne 0) { + throw "Unable to fetch vcpkg baseline $baseline." + } + } uv sync --project build --frozen - name: Provision external verification tools diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index c2cf292..c9c96d6 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -76,6 +76,17 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertIn('version: "0.12.1"', workflow) self.assertIn("id: runner-image", workflow) self.assertIn("${{ steps.runner-image.outputs.identity }}", workflow) + self.assertIn('"TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow) + self.assertIn('"TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow) + self.assertIn( + "$baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | " + "ConvertFrom-Json).'builtin-baseline'", + workflow, + ) + self.assertIn( + "git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline", + workflow, + ) self.assertIn("C:\\Program Files\\Cppcheck", workflow) self.assertIn("nuget install Microsoft.CodeAnalysis.BinSkim", workflow) self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow) From 91b5d722a4c66d94f61f78455c506213379350ae Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 17:42:47 +1000 Subject: [PATCH 10/17] Pin hosted vcpkg worktree to manifest baseline --- .github/workflows/main.yml | 9 +++++++++ build/tests/test_workflow_contract.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aabc69b..e4e5a06 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -82,6 +82,15 @@ jobs: throw "Unable to fetch vcpkg baseline $baseline." } } + git -C $env:VCPKG_ROOT -c advice.detachedHead=false ` + checkout --detach --force $baseline + if ($LASTEXITCODE -ne 0) { + throw "Unable to check out vcpkg baseline $baseline." + } + & (Join-Path $env:VCPKG_ROOT 'bootstrap-vcpkg.bat') -disableMetrics + if ($LASTEXITCODE -ne 0) { + throw "Unable to bootstrap vcpkg baseline $baseline." + } uv sync --project build --frozen - name: Provision external verification tools diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index c9c96d6..84466d0 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -87,6 +87,8 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: "git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline", workflow, ) + self.assertIn("checkout --detach --force $baseline", workflow) + self.assertIn("bootstrap-vcpkg.bat", workflow) self.assertIn("C:\\Program Files\\Cppcheck", workflow) self.assertIn("nuget install Microsoft.CodeAnalysis.BinSkim", workflow) self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow) From 332e3c675a5f795a18768faf841652c9af81655e Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 18:24:07 +1000 Subject: [PATCH 11/17] Stabilize UMDH leak capture on hosted runners --- build/core/leak.py | 63 ++++++++++++++++-- build/tests/test_leak_graph.py | 113 ++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/build/core/leak.py b/build/core/leak.py index d7c0ad9..53f151d 100644 --- a/build/core/leak.py +++ b/build/core/leak.py @@ -11,7 +11,9 @@ import shutil import subprocess import sys +import tempfile +from filelock import FileLock import psutil @@ -134,6 +136,43 @@ def _kill_tree(process: psutil.Popen[str]) -> None: psutil.wait_procs(processes, timeout=5) +def _run_gflags(gflags: Path, image: str, flag: str | None = None) -> subprocess.CompletedProcess[str]: + command = [str(gflags), "/i", image] + if flag is not None: + command.append(flag) + return subprocess.run( + command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace", + ) + + +def _change_stack_traces(gflags: Path, image: str, enabled: bool) -> None: + flag = "+ust" if enabled else "-ust" + result = _run_gflags(gflags, image, flag) + if result.returncode: + raise LeakError(f"GFlags {flag} failed ({result.returncode}): {result.stdout}") + + +def _enable_stack_traces(gflags: Path, image: str) -> bool: + if not gflags.is_file(): + return False + current = _run_gflags(gflags, image) + if current.returncode: + raise LeakError(f"GFlags query failed ({current.returncode}): {current.stdout}") + match = re.search(r"are:\s*([0-9A-Fa-f]+)\s*$", current.stdout) + if match is None: + raise LeakError(f"GFlags returned an unrecognized setting: {current.stdout}") + if int(match.group(1), 16) & 0x1000: + return False + try: + _change_stack_traces(gflags, image, True) + except OSError as error: + if getattr(error, "winerror", None) != 740: + raise + return False + return True + + def _snapshot(umdh: Path, pid: int, destination: Path, baseline: bool) -> None: result = subprocess.run([str(umdh), f"-p:{pid}", f"-f:{destination}"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") text = destination.read_text(encoding="utf-8", errors="replace") if destination.is_file() else "" @@ -148,15 +187,25 @@ def _capture(args: Sequence[str]) -> None: directory, umdh_value, mode, scenario, warmup_value, iterations_value, windows_value = _exact("capture", args, 7) _selection(mode, scenario) probe, umdh = _file(str(Path(directory) / BINARIES[0]), "leak probe"), _file(umdh_value, "UMDH") + gflags = umdh.with_name("gflags.exe") warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3) output, snapshot_dir = _output(), _output() / "snapshots" snapshot_dir.mkdir() environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"} command = [str(probe), "--mode", mode, "--scenario", scenario, "--warmup", str(warmup), "--iterations", str(iterations), "--windows", str(windows)] error_path = output / "probe.stderr.log" + process: psutil.Popen[str] | None = None with error_path.open("w+", encoding="utf-8") as errors: - process = psutil.Popen(command, cwd=directory, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True, encoding="utf-8", errors="replace") try: + lock = FileLock(Path(tempfile.gettempdir()) / "observer-modules-gflags.lock") + with lock: + changed = _enable_stack_traces(gflags, probe.name) + try: + process = psutil.Popen(command, cwd=directory, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True, encoding="utf-8", errors="replace") + finally: + if changed: + _change_stack_traces(gflags, probe.name, False) + assert process is not None _ready(_read(process, "READY"), mode, scenario, process.pid) for label in ("baseline", *(f"window-{index}" for index in range(1, windows + 1))): line = _read(process, "SNAPSHOT", label) @@ -175,11 +224,13 @@ def _capture(args: Sequence[str]) -> None: errors.flush(); errors.seek(0) raise LeakError(f"leak probe failed ({result}): {errors.read()}") finally: - if process.poll() is None: - _kill_tree(process) - assert process.stdin is not None and process.stdout is not None - process.stdin.close() - process.stdout.close() + if process is not None: + if process.poll() is None: + _kill_tree(process) + assert process.stdin is not None and process.stdout is not None + process.stdin.close() + process.stdout.close() + assert process is not None _json("capture.json", {"mode": mode, "scenario": scenario, "processId": process.pid, "windows": windows}) diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py index b0a481c..7eb766f 100644 --- a/build/tests/test_leak_graph.py +++ b/build/tests/test_leak_graph.py @@ -385,6 +385,8 @@ def test_capture_streams_stdout_redirects_stderr_to_file_and_uses_psutil(self) - (root / "leak-probe.exe").touch() umdh = root / "umdh.exe" umdh.touch() + gflags = root / "gflags.exe" + gflags.touch() lines = [ "probe startup noise", "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", @@ -394,14 +396,26 @@ def test_capture_streams_stdout_redirects_stderr_to_file_and_uses_psutil(self) - process = FakeProbe(lines) def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if Path(argv[0]) == gflags: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return subprocess.CompletedProcess(argv, 0, output) Path(argv[-1].removeprefix("-f:")).write_text("BackTrace 1\n", encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "") with self.output(root), mock.patch("core.leak.psutil.Popen", return_value=process) as popen, mock.patch( "core.leak.subprocess.run", side_effect=snapshot - ): + ) as invoked: leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + commands = [call.args[0] for call in invoked.call_args_list] + self.assertEqual( + commands[:2], + [ + [str(gflags), "/i", "leak-probe.exe"], + [str(gflags), "/i", "leak-probe.exe", "+ust"], + ], + ) + self.assertEqual([str(gflags), "/i", "leak-probe.exe", "-ust"], commands[2]) self.assertIsNot(popen.call_args.kwargs["stderr"], subprocess.PIPE) self.assertEqual("continue|baseline\ncontinue|window-1\ncontinue|window-2\ncontinue|window-3\n", process.stdin.getvalue()) self.assertTrue(process.stdin.was_closed) @@ -414,6 +428,8 @@ def test_capture_kills_the_probe_tree_on_protocol_timeout_or_exit_failure(self) (root / "leak-probe.exe").touch() umdh = root / "umdh.exe" umdh.touch() + gflags = root / "gflags.exe" + gflags.touch() cases = ( FakeProbe(["OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed"]), FakeProbe([ @@ -433,6 +449,9 @@ def test_capture_kills_the_probe_tree_on_protocol_timeout_or_exit_failure(self) ) def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if Path(argv[0]) == gflags: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return subprocess.CompletedProcess(argv, 0, output) Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "") @@ -448,6 +467,98 @@ def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess if process.timeout or process.result == 0: self.assertTrue(process.killed) + rejected = root / "gflags-rejected" + rejected.mkdir() + failure = subprocess.CompletedProcess([], 1, "access denied") + + def reject(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if len(argv) == 3: + return subprocess.CompletedProcess( + argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000" + ) + return failure + + with self.output(rejected), mock.patch( + "core.leak.subprocess.run", side_effect=reject + ), mock.patch("core.leak.psutil.Popen") as popen, self.assertRaisesRegex( + LeakError, "GFlags \\+ust failed" + ): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + popen.assert_not_called() + + cleanup = root / "gflags-cleanup" + cleanup.mkdir() + + def reject_cleanup(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return failure if argv[-1] == "-ust" else subprocess.CompletedProcess(argv, 0, output) + + process = FakeProbe([]) + with self.output(cleanup), mock.patch( + "core.leak.subprocess.run", side_effect=reject_cleanup + ), mock.patch("core.leak.psutil.Popen", return_value=process), mock.patch( + "core.leak.psutil.wait_procs" + ), self.assertRaisesRegex(LeakError, "GFlags -ust failed"): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + self.assertTrue(process.killed) + + elevation = root / "gflags-elevation" + elevation.mkdir() + elevated = OSError("requires elevation") + elevated.winerror = 740 # type: ignore[attr-defined] + calls = 0 + + def unavailable(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + nonlocal calls + if Path(argv[0]) == gflags: + calls += 1 + if calls == 1: + return subprocess.CompletedProcess( + argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000" + ) + raise elevated + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + process = FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ]) + with self.output(elevation), mock.patch( + "core.leak.subprocess.run", side_effect=unavailable + ), mock.patch("core.leak.psutil.Popen", return_value=process) as popen: + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + popen.assert_called_once() + + existing = mock.Mock(returncode=0, stdout="Current Registry Settings for leak-probe.exe executable are: 00001000") + with mock.patch("core.leak.subprocess.run", return_value=existing) as invoked: + self.assertFalse(leak._enable_stack_traces(gflags, "leak-probe.exe")) + invoked.assert_called_once() + + def test_stack_trace_activation_handles_missing_and_invalid_gflags(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + missing = root / "missing.exe" + self.assertFalse(leak._enable_stack_traces(missing, "probe.exe")) + + gflags = root / "gflags.exe" + gflags.touch() + for result, message in ( + (mock.Mock(returncode=1, stdout="denied"), "query failed"), + (mock.Mock(returncode=0, stdout="unexpected"), "unrecognized"), + ): + with self.subTest(message=message), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaisesRegex(LeakError, message): + leak._enable_stack_traces(gflags, "probe.exe") + + query = mock.Mock(returncode=0, stdout="Current Registry Settings for probe.exe executable are: 00000000") + unexpected = OSError("unexpected launch failure") + unexpected.winerror = 5 # type: ignore[attr-defined] + with mock.patch("core.leak.subprocess.run", side_effect=(query, unexpected)), self.assertRaises(OSError): + leak._enable_stack_traces(gflags, "probe.exe") + def test_diff_and_judge_preserve_growth_evidence_and_reject_leaks(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 5fa1448bdca32b124eaec967d2a2da782d8806bd Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 18:44:15 +1000 Subject: [PATCH 12/17] Handle empty hosted GFlags state --- build/core/leak.py | 8 ++++++-- build/tests/test_leak_graph.py | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/build/core/leak.py b/build/core/leak.py index 53f151d..ed3f06a 100644 --- a/build/core/leak.py +++ b/build/core/leak.py @@ -160,9 +160,13 @@ def _enable_stack_traces(gflags: Path, image: str) -> bool: if current.returncode: raise LeakError(f"GFlags query failed ({current.returncode}): {current.stdout}") match = re.search(r"are:\s*([0-9A-Fa-f]+)\s*$", current.stdout) - if match is None: + if current.stdout.startswith("No Registry Settings for "): + flags = 0 + elif match is not None: + flags = int(match.group(1), 16) + else: raise LeakError(f"GFlags returned an unrecognized setting: {current.stdout}") - if int(match.group(1), 16) & 0x1000: + if flags & 0x1000: return False try: _change_stack_traces(gflags, image, True) diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py index 7eb766f..0d06ad3 100644 --- a/build/tests/test_leak_graph.py +++ b/build/tests/test_leak_graph.py @@ -544,6 +544,11 @@ def test_stack_trace_activation_handles_missing_and_invalid_gflags(self) -> None gflags = root / "gflags.exe" gflags.touch() + absent = mock.Mock(returncode=0, stdout="No Registry Settings for probe.exe executable") + changed = mock.Mock(returncode=0, stdout="") + with mock.patch("core.leak.subprocess.run", side_effect=(absent, changed)): + self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe")) + for result, message in ( (mock.Mock(returncode=1, stdout="denied"), "query failed"), (mock.Mock(returncode=0, stdout="unexpected"), "unrecognized"), From 384128bcd9e33a48a4bda33dd2de85a2219528c7 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 19:16:15 +1000 Subject: [PATCH 13/17] Use compatible UMDH in hosted leak checks --- .github/workflows/main.yml | 33 +++++++++++++++++++++++++++ build/core/leak.py | 31 +++++++++++++++++++++---- build/core/quality_tools.py | 6 +++++ build/tests/test_leak_graph.py | 11 ++++++++- build/tests/test_quality_tools.py | 11 +++++++++ build/tests/test_workflow_contract.py | 5 ++++ docs/build-system.md | 4 ++++ 7 files changed, 95 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e4e5a06..20f3d68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -134,6 +134,39 @@ jobs: throw "UMDH was not found after Windows Debugging Tools setup: $umdh" } + - name: Provision the Windows 10 UMDH workaround + if: matrix.job == 'x64' + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'winsdk-19041' + $installer = Join-Path $env:RUNNER_TEMP 'winsdksetup-19041.exe' + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2311805' -OutFile $installer + $signature = Get-AuthenticodeSignature -LiteralPath $installer + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { + throw "Windows 10 SDK installer signature validation failed: $($signature.Status)" + } + $process = Start-Process -FilePath $installer -ArgumentList @( + '/features', 'OptionId.WindowsDesktopDebuggers', + '/installpath', $root, + '/quiet', '/norestart', '/ceip', 'off' + ) -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Windows 10 Debugging Tools installation failed with exit code $($process.ExitCode)." + } + $umdh = Join-Path $root 'Debuggers\x64\umdh.exe' + $gflags = Join-Path $root 'Debuggers\x64\gflags.exe' + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + throw "Windows 10 UMDH was not found after setup: $umdh" + } + if (-not (Test-Path -LiteralPath $gflags -PathType Leaf)) { + throw "Windows 10 GFlags was not found after setup: $gflags" + } + $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($umdh).FileVersion + if (-not $version.StartsWith('10.0.19041.', [StringComparison]::Ordinal)) { + throw "Unexpected Windows 10 UMDH version: $version" + } + "OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV + - name: Check prerequisites through the public entry point shell: pwsh run: ./build.ps1 doctor diff --git a/build/core/leak.py b/build/core/leak.py index ed3f06a..e96c4b1 100644 --- a/build/core/leak.py +++ b/build/core/leak.py @@ -159,11 +159,16 @@ def _enable_stack_traces(gflags: Path, image: str) -> bool: current = _run_gflags(gflags, image) if current.returncode: raise LeakError(f"GFlags query failed ({current.returncode}): {current.stdout}") - match = re.search(r"are:\s*([0-9A-Fa-f]+)\s*$", current.stdout) + match = re.search( + r"are:\s*([0-9A-Fa-f]{8}(?:\s*:\s*[0-9A-Fa-f]{8})*)\s*$", + current.stdout, + ) if current.stdout.startswith("No Registry Settings for "): flags = 0 elif match is not None: - flags = int(match.group(1), 16) + flags = 0 + for value in match.group(1).split(":"): + flags |= int(value.strip(), 16) else: raise LeakError(f"GFlags returned an unrecognized setting: {current.stdout}") if flags & 0x1000: @@ -177,8 +182,18 @@ def _enable_stack_traces(gflags: Path, image: str) -> bool: return True -def _snapshot(umdh: Path, pid: int, destination: Path, baseline: bool) -> None: - result = subprocess.run([str(umdh), f"-p:{pid}", f"-f:{destination}"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") +def _snapshot( + umdh: Path, pid: int, destination: Path, baseline: bool, environment: dict[str, str] +) -> None: + result = subprocess.run( + [str(umdh), f"-p:{pid}", f"-f:{destination}"], + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf-8", + errors="replace", + ) text = destination.read_text(encoding="utf-8", errors="replace") if destination.is_file() else "" if baseline: if result.returncode not in (0, 1) or (result.returncode == 1 and "enabled allocation stack collection" not in text): @@ -215,7 +230,13 @@ def _capture(args: Sequence[str]) -> None: line = _read(process, "SNAPSHOT", label) if not re.search(rf"\|pid={process.pid}\|", line): raise LeakError("leak SNAPSHOT marker has the wrong PID") - _snapshot(umdh, process.pid, snapshot_dir / f"{label}.txt", label == "baseline") + _snapshot( + umdh, + process.pid, + snapshot_dir / f"{label}.txt", + label == "baseline", + environment, + ) assert process.stdin is not None process.stdin.write(f"continue|{label}\n") process.stdin.flush() diff --git a/build/core/quality_tools.py b/build/core/quality_tools.py index 9ccc18d..5ecff94 100644 --- a/build/core/quality_tools.py +++ b/build/core/quality_tools.py @@ -96,6 +96,12 @@ def resolve_binskim() -> ResolvedTool: def resolve_umdh() -> ResolvedTool: + override = os.environ.get("OBSERVER_UMDH") + if override: + path = Path(override) + if not path.is_file(): + raise FileNotFoundError(f"missing UMDH override: {path}") + return resolve_tool(path, "UMDH") program_files = os.environ.get("ProgramFiles(x86)") candidates = (() if not program_files else tuple( Path(program_files) / f"Windows Kits/{version}/Debuggers/x64/umdh.exe" diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py index 0d06ad3..af9a250 100644 --- a/build/tests/test_leak_graph.py +++ b/build/tests/test_leak_graph.py @@ -399,6 +399,8 @@ def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess if Path(argv[0]) == gflags: output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" return subprocess.CompletedProcess(argv, 0, output) + self.assertEqual(str(root), _options["env"]["_NT_SYMBOL_PATH"]) + self.assertEqual("1", _options["env"]["OANOCACHE"]) Path(argv[-1].removeprefix("-f:")).write_text("BackTrace 1\n", encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "") @@ -549,6 +551,13 @@ def test_stack_trace_activation_handles_missing_and_invalid_gflags(self) -> None with mock.patch("core.leak.subprocess.run", side_effect=(absent, changed)): self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe")) + dual_view = mock.Mock( + returncode=0, + stdout="Current Registry Settings for probe.exe executable are: 00000000 : 00000000", + ) + with mock.patch("core.leak.subprocess.run", side_effect=(dual_view, changed)): + self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe")) + for result, message in ( (mock.Mock(returncode=1, stdout="denied"), "query failed"), (mock.Mock(returncode=0, stdout="unexpected"), "unrecognized"), @@ -661,7 +670,7 @@ def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> No with self.subTest(snapshot=(result.returncode, baseline, content)), mock.patch( "core.leak.subprocess.run", return_value=result ), self.assertRaises(LeakError): - leak._snapshot(Path("umdh"), 1, destination, baseline) + leak._snapshot(Path("umdh"), 1, destination, baseline, {}) report = root / "report.txt" diff --git a/build/tests/test_quality_tools.py b/build/tests/test_quality_tools.py index a01329f..2e8cf6d 100644 --- a/build/tests/test_quality_tools.py +++ b/build/tests/test_quality_tools.py @@ -138,6 +138,17 @@ def test_windows_kit_10_is_the_deterministic_fallback(self) -> None: tools = discover_quality_tools(toolchain) # type: ignore[arg-type] self.assertIn("Windows Kits\\10", str(tools.umdh.path)) + def test_explicit_umdh_override_is_strict(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + umdh = Path(temporary) / "sdk-19041/Debuggers/x64/umdh.exe" + umdh.parent.mkdir(parents=True) + umdh.write_bytes(b"umdh-19041") + with mock.patch.dict(os.environ, {"OBSERVER_UMDH": str(umdh)}, clear=False): + self.assertEqual(umdh.resolve(), resolve_umdh().path) + umdh.unlink() + with self.assertRaisesRegex(FileNotFoundError, "missing UMDH override"): + resolve_umdh() + def test_selective_resolvers_are_lazy_and_match_the_aggregate(self) -> None: with tempfile.TemporaryDirectory() as temporary: toolchain, binskim, program_files = self.fixture(Path(temporary)) diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index 84466d0..b8c7507 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -94,6 +94,11 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow) self.assertNotIn("dotnet tool install --global Microsoft.CodeAnalysis.BinSkim", workflow) self.assertIn("$env:GITHUB_PATH", workflow) + self.assertIn("if: matrix.job == 'x64'", workflow) + self.assertIn("https://go.microsoft.com/fwlink/?linkid=2311805", workflow) + self.assertIn("'10.0.19041.'", workflow) + self.assertIn("'Debuggers\\x64\\gflags.exe'", workflow) + self.assertIn('"OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV', workflow) for duplicated_gate in ( "./build.ps1 source-checks", diff --git a/docs/build-system.md b/docs/build-system.md index 37db274..755b058 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -295,6 +295,10 @@ The workflow first runs `doctor`, then exactly one public verification command. evidence bundle and uploads the separate package bundle only from successful `master` architecture jobs. Branch protection requires `source`, `x86`, `x64`, and `arm64-cross`. +The hosted x64 job selects UMDH from the serviced Windows 10 SDK 2004 line explicitly. This avoids the documented +allocation-stack capture defect in the UMDH shipped with Windows 11 SDKs without changing the locally runnable leak +gate or the SDK used to compile production binaries. + ### Future analysis backlog The following tools are deliberately recorded for later work so that they are not lost while the build and test From 5231a2422d284a9f16d8fe87b4582e4a171e3364 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 19:33:31 +1000 Subject: [PATCH 14/17] Extract pinned Windows 10 UMDH payload --- .github/workflows/main.yml | 31 ++++++++++++++++----------- build/tests/test_workflow_contract.py | 15 +++++++++++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20f3d68..7c9c955 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -139,22 +139,27 @@ jobs: shell: pwsh run: | $root = Join-Path $env:RUNNER_TEMP 'winsdk-19041' - $installer = Join-Path $env:RUNNER_TEMP 'winsdksetup-19041.exe' - Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2311805' -OutFile $installer - $signature = Get-AuthenticodeSignature -LiteralPath $installer + $extract = Join-Path $root 'extracted' + $msi = Join-Path $root 'debuggers-x64.msi' + New-Item -ItemType Directory -Force -Path $root | Out-Null + Invoke-WebRequest -Uri 'https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi' -OutFile $msi + $expected = '354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B' + if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash -ne $expected) { + throw 'Windows 10 Debugging Tools payload hash validation failed.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $msi if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { - throw "Windows 10 SDK installer signature validation failed: $($signature.Status)" + throw "Windows 10 Debugging Tools payload signature validation failed: $($signature.Status)" } - $process = Start-Process -FilePath $installer -ArgumentList @( - '/features', 'OptionId.WindowsDesktopDebuggers', - '/installpath', $root, - '/quiet', '/norestart', '/ceip', 'off' - ) -Wait -PassThru - if ($process.ExitCode -notin @(0, 3010)) { - throw "Windows 10 Debugging Tools installation failed with exit code $($process.ExitCode)." + $arguments = @('/a', ('`"' + $msi + '`"'), '/qn', '/norestart', ('TARGETDIR=`"' + $extract + '`"')) + $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` + -ArgumentList $arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Windows 10 Debugging Tools extraction failed with exit code $($process.ExitCode)." } - $umdh = Join-Path $root 'Debuggers\x64\umdh.exe' - $gflags = Join-Path $root 'Debuggers\x64\gflags.exe' + $debuggers = Join-Path $extract 'Windows Kits\10\Debuggers\x64' + $umdh = Join-Path $debuggers 'umdh.exe' + $gflags = Join-Path $debuggers 'gflags.exe' if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { throw "Windows 10 UMDH was not found after setup: $umdh" } diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index b8c7507..bbd2c0a 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -95,9 +95,20 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertNotIn("dotnet tool install --global Microsoft.CodeAnalysis.BinSkim", workflow) self.assertIn("$env:GITHUB_PATH", workflow) self.assertIn("if: matrix.job == 'x64'", workflow) - self.assertIn("https://go.microsoft.com/fwlink/?linkid=2311805", workflow) + self.assertIn( + "https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/" + "windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi", + workflow, + ) + self.assertIn("354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B", workflow) + self.assertIn( + '$process = Start-Process -FilePath "$env:SystemRoot\\System32\\msiexec.exe"', + workflow, + ) + self.assertNotIn("'/layout'", workflow) + self.assertNotIn("'/installpath'", workflow) self.assertIn("'10.0.19041.'", workflow) - self.assertIn("'Debuggers\\x64\\gflags.exe'", workflow) + self.assertIn("$gflags = Join-Path $debuggers 'gflags.exe'", workflow) self.assertIn('"OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV', workflow) for duplicated_gate in ( From 60a49bb109aff71f999f3762c084a2e49f274469 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 19:37:34 +1000 Subject: [PATCH 15/17] Cache completed CI build nodes --- .github/workflows/main.yml | 13 ++++++++++++- build/tests/test_workflow_contract.py | 13 ++++++++++++- docs/build-system.md | 8 +++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7c9c955..5cb06cd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -65,6 +65,17 @@ jobs: path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} + - name: Cache completed build nodes + if: matrix.job != 'source' + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: out/cas + key: cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-${{ github.sha }}- + cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}- + save-always: true + - name: Prepare the pinned Python environment shell: pwsh run: | @@ -151,7 +162,7 @@ jobs: if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { throw "Windows 10 Debugging Tools payload signature validation failed: $($signature.Status)" } - $arguments = @('/a', ('`"' + $msi + '`"'), '/qn', '/norestart', ('TARGETDIR=`"' + $extract + '`"')) + $arguments = @('/a', $msi, '/qn', '/norestart', "TARGETDIR=$extract") $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` -ArgumentList $arguments -Wait -PassThru if ($process.ExitCode -ne 0) { diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index bbd2c0a..88fce84 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -52,7 +52,14 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertIn("/packages", workflow) self.assertNotIn("if-no-files-found: ignore", workflow) self.assertIn("permissions:\n contents: read", workflow) - self.assertNotIn("out/cas", workflow) + self.assertEqual(workflow.count("path: out/cas"), 1) + self.assertIn( + "key: cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-" + "${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}", + workflow, + ) + self.assertIn("cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-", workflow) + self.assertIn("save-always: true", workflow) self.assertNotIn("out/work", workflow) self.assertNotIn("contents: write", workflow) self.assertNotIn("download-artifact", workflow) @@ -105,6 +112,10 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: '$process = Start-Process -FilePath "$env:SystemRoot\\System32\\msiexec.exe"', workflow, ) + self.assertIn( + '$arguments = @(\'/a\', $msi, \'/qn\', \'/norestart\', "TARGETDIR=$extract")', + workflow, + ) self.assertNotIn("'/layout'", workflow) self.assertNotIn("'/installpath'", workflow) self.assertIn("'10.0.19041.'", workflow) diff --git a/docs/build-system.md b/docs/build-system.md index 755b058..f4fc27c 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -282,9 +282,11 @@ Pull requests use the same gates and thresholds with shorter explicit bounded-wo hidden `pr`/`full` gate composition and no manual profile; changing a bounded duration never removes a target or weakens the 100% coverage and zero-finding gates. -The workflow may cache only dependency transport data: uv downloads/environment data and the vcpkg binary cache keyed -by pinned manifests, triplets, and toolchain identity. It does not cache `out/cas`, `out/work`, completed packages, or -an installed vcpkg tree. A cold cache may make a run slower but can never change its gates. +The workflow caches dependency transport data and completed content-addressed build nodes. CAS caches are isolated by +hosted-runner image and architecture job, restored only within GitHub's branch/ref cache scope, and never shared with +the source job. Node identities still cover recipes, declared inputs, dependency identities, configuration, runtime, +and toolchain content; fuzz and leak nodes include a run nonce. An absent or evicted cache therefore changes only +latency. The workflow never caches `out/work`, packages, or an installed vcpkg tree. Each job uploads its `-ExportDir` with `if: always()` so reports from completed independent branches survive a later failure. Pull-request evidence is retained for seven days; `master` evidence for thirty days. Release ZIPs and PDBs are From 6ab9772adb2a79121eaa1a730a8931e5c9aacaeb Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 21:54:24 +1000 Subject: [PATCH 16/17] Stabilize and prune CI build cache --- .github/workflows/main.yml | 21 ++- build/core/clean.py | 76 +++++++- build/core/result_export.py | 25 +-- build/core/runtime.py | 94 ++++++++-- build/core/toolchain.py | 37 +++- build/driver.py | 93 +++++++--- build/graphs/analysis.py | 121 +++++++------ build/graphs/fuzz.py | 26 +-- build/main.py | 12 +- build/tests/test_analysis_graph.py | 198 ++++++++++++++------ build/tests/test_clean.py | 179 ++++++++++++++++++ build/tests/test_driver.py | 217 +++++++++++++++++++++- build/tests/test_graph_main_coverage.py | 4 +- build/tests/test_instrumented_graph.py | 229 ++++++++++++++++++++---- build/tests/test_main.py | 20 ++- build/tests/test_result_export.py | 29 +++ build/tests/test_runtime.py | 146 ++++++++++++++- build/tests/test_toolchain.py | 38 +++- build/tests/test_workflow_contract.py | 22 ++- docs/build-system.md | 105 +++++++---- 20 files changed, 1424 insertions(+), 268 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5cb06cd..973fa73 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -65,16 +65,16 @@ jobs: path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - name: Cache completed build nodes + - name: Restore completed build nodes + id: restore-build-cas if: matrix.job != 'source' - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: out/cas - key: cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | - cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-${{ github.sha }}- - cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}- - save-always: true + cas-v1-${{ matrix.job }}-${{ github.sha }}- + cas-v1-${{ matrix.job }}- - name: Prepare the pinned Python environment shell: pwsh @@ -206,7 +206,14 @@ jobs: $leakIterations = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 100 } ./build.ps1 verify-arch -Arch '${{ matrix.arch }}' -ExportDir $evidence ` -TestShards 4 -FuzzSeconds $fuzzSeconds -LeakWarmup $leakWarmup ` - -LeakIterations $leakIterations -LeakWindows 3 + -LeakIterations $leakIterations -LeakWindows 3 -PruneCas + + - name: Save completed build nodes + if: always() && matrix.job != 'source' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: out/cas + key: ${{ steps.restore-build-cas.outputs.cache-primary-key }} - name: Upload verification evidence if: always() && (steps.verify-source.outcome != 'skipped' || steps.verify-arch.outcome != 'skipped') diff --git a/build/core/clean.py b/build/core/clean.py index 434566e..546d3b6 100644 --- a/build/core/clean.py +++ b/build/core/clean.py @@ -3,7 +3,7 @@ from __future__ import annotations import argparse -from collections.abc import Sequence +from collections.abc import Collection, Sequence import os from pathlib import Path import shutil @@ -90,6 +90,80 @@ def _require_inactive(paths: BuildPaths) -> tuple[Path, ...]: return tuple(inactive) +def _require_no_active_runs(paths: BuildPaths, owned_lease: Path | None) -> None: + owned_active = owned_lease is None + for entry in sorted(paths.locks_root.iterdir()): + if not (entry.name.startswith("run-") and entry.suffix == ".lease"): + continue + try: + paths.lease(entry.name[4:-6]) + except PathSafetyError as error: + raise CleanError(f"unexpected run lease: {entry}") from error + lock = FileLock( + entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True + ) + try: + with lock: + if entry == owned_lease: + raise CleanError(f"owned run lease is not active: {entry}") + except Timeout as error: + if entry == owned_lease: + owned_active = True + continue + raise CleanError(f"active build lease: {entry}") from error + if not owned_active: + raise CleanError(f"owned run lease is missing: {owned_lease}") + + +def sweep_cas( + repository: Path | str, live_uids: Collection[str], *, owned_run_id: str | None = None +) -> tuple[Path, ...]: + """Remove unlocked canonical CAS entries absent from the explicit live set.""" + + paths = BuildPaths(repository) + owned_lease = paths.lease(owned_run_id) if owned_run_id is not None else None + live = frozenset(live_uids) + for uid in live: + paths.cas(uid) + if not _validate(paths): + return () + paths.work_root.mkdir(exist_ok=True) + paths.locks_root.mkdir(exist_ok=True) + coordination = FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with coordination: + if not _validate(paths) or not paths.cas_root.exists(): + return () + _require_no_active_runs(paths, owned_lease) + removed = [] + for entry in sorted(paths.cas_root.iterdir()): + uid = entry.name + if ( + len(uid) != 32 + or any(character not in "0123456789abcdef" for character in uid) + or uid in live + ): + continue + candidate = paths.cas(uid) + node = FileLock( + paths.lock(uid), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with node: + candidate = paths.cas(uid) + shutil.rmtree(candidate.entry) + except Timeout: + continue + removed.append(candidate.entry) + return tuple(removed) + except Timeout as error: + raise CleanError("active build coordination lock") from error + + def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]: """Remove all output or inactive work, including legacy CAS entry names.""" diff --git a/build/core/result_export.py b/build/core/result_export.py index 767c538..0218e66 100644 --- a/build/core/result_export.py +++ b/build/core/result_export.py @@ -10,7 +10,7 @@ import shutil import stat import tempfile -from typing import Any, Iterable +from typing import Any, Iterable, Mapping from core.graph import Graph, Node, Result from core.store import CasStore @@ -210,6 +210,7 @@ def export_results( status: str, *, failures: Iterable[str] = (), + cache: Mapping[str, object] | None = None, ) -> Path: """Publish complete declared results and a relative-path-only manifest.""" @@ -270,17 +271,17 @@ def export_results( logs.append( {"node": current.name, "path": relative, "sha256": digest, "size": size} ) - _write_manifest( - staging, - { - "schema": 1, - "command": command, - "status": status, - "failures": list(failure_names), - "results": results, - "logs": logs, - }, - ) + document: dict[str, object] = { + "schema": 1, + "command": command, + "status": status, + "failures": list(failure_names), + "results": results, + "logs": logs, + } + if cache is not None: + document["cache"] = dict(cache) + _write_manifest(staging, document) if _lstat(published) is not None: raise ResultExportError(f"export destination already exists: {published}") staging.rename(published) diff --git a/build/core/runtime.py b/build/core/runtime.py index ebd15f1..929e6f8 100644 --- a/build/core/runtime.py +++ b/build/core/runtime.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio -from contextlib import suppress +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress from pathlib import Path import shutil +import time from filelock import AsyncFileLock @@ -26,15 +28,11 @@ def __init__(self, runtime: BuildRuntime, executor: Executor) -> None: self._executor = executor async def run(self) -> None: - coordination = self._runtime._lock(self._runtime.paths.coordination_lock()) - lease = self._runtime._lock(self._runtime.paths.lease(self._runtime._run_id)) - async with coordination: - self._runtime.paths.prepare() - await lease.acquire() - try: + if self._runtime.owned_run_id is not None: + await self._executor.run() + return + async with self._runtime.session(): await self._executor.run() - finally: - await lease.release() class BuildRuntime: @@ -50,9 +48,75 @@ def __init__( self.store = CasStore(self.paths, run_id) self._run_id = run_id self._runner = process_runner + self._observed: dict[str, Node] = {} + self._durations_ms: dict[str, int] = {} + self._session_lease: AsyncFileLock | None = None + + @property + def owned_run_id(self) -> str | None: + return self._run_id if self._session_lease is not None else None + + @asynccontextmanager + async def session(self) -> AsyncIterator[None]: + """Hold this runtime's run lease across a complete public operation.""" + + if self._session_lease is not None: + raise RuntimeError("build runtime session is already active") + coordination = self._lock(self.paths.coordination_lock()) + lease = self._lock(self.paths.lease(self._run_id)) + async with coordination: + self.paths.prepare() + await lease.acquire() + self._session_lease = lease + try: + yield + finally: + self._session_lease = None + await lease.release() def is_complete(self, current: Node) -> bool: - return self.store.is_complete(current) + complete = self.store.is_complete(current) + self._observed.setdefault(current.uid, current) + return complete + + def _known_nodes(self, graph: Graph | None) -> dict[str, Node]: + known = dict(self._observed) + if graph is not None: + for current in graph.nodes: + known.setdefault(current.uid, current) + return known + + def live_uids(self, graph: Graph | None = None) -> tuple[str, ...]: + """Return known graph nodes that have a published completion marker.""" + + return tuple(sorted( + uid for uid, current in self._known_nodes(graph).items() + if self.store.is_complete(current) + )) + + def cache_report(self, graph: Graph | None = None) -> dict[str, object]: + """Describe deterministic node identities and their result in this process.""" + + nodes: list[dict[str, object]] = [] + summary = {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0} + for uid, current in sorted( + self._known_nodes(graph).items(), key=lambda item: (item[1].name, item[0]) + ): + complete = self.store.is_complete(current) + if uid in self._durations_ms: + state = "executed" if complete else "failed" + elif complete: + state = "hit" + else: + state = "incomplete" + summary[state] += 1 + nodes.append({ + "duration_ms": self._durations_ms.get(uid, 0), + "name": current.name, + "state": state, + "uid": uid, + }) + return {"schema": 1, "summary": summary, "nodes": nodes} @staticmethod def _lock(path: Path) -> AsyncFileLock: @@ -68,6 +132,16 @@ def lock(self, current: Node) -> AsyncFileLock: return self._lock(self.paths.lock(current.uid)) async def run(self, current: Node) -> None: + self._observed.setdefault(current.uid, current) + started = time.perf_counter() + try: + await self._run_node(current) + finally: + self._durations_ms[current.uid] = max( + 0, round((time.perf_counter() - started) * 1000) + ) + + async def _run_node(self, current: Node) -> None: reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR", "_MSPDBSRV_ENDPOINT_") existing = {key.casefold() for key, _value in current.command.env} for key in reserved: diff --git a/build/core/toolchain.py b/build/core/toolchain.py index c078781..68b98a5 100644 --- a/build/core/toolchain.py +++ b/build/core/toolchain.py @@ -22,6 +22,32 @@ class MsvcToolchain: identity: tuple[tuple[str, str], ...] +_DEVELOPER_VARIABLES = frozenset( + { + "devenvdir", + "extensionsdkdir", + "external_include", + "include", + "lib", + "libpath", + "netfxsdkdir", + "ucrtversion", + "universalcrtsdkdir", + "vcideinstalldir", + "vcinstalldir", + "visualstudioversion", + "vs170comntools", + "vsinstalldir", + "windowslibpath", + } +) +_DEVELOPER_PREFIXES = ("framework", "vctools", "vscmd_", "windowssdk") + + +def _developer_variable(name: str) -> bool: + return name in _DEVELOPER_VARIABLES or name.startswith(_DEVELOPER_PREFIXES) + + def _existing(path: Path | str | None, directory: bool = False) -> Path: resolved = Path(path).resolve() if path else None if resolved is None or not (resolved.is_dir() if directory else resolved.is_file()): @@ -36,6 +62,8 @@ def _command_environment(text: str) -> tuple[tuple[str, str], ...]: continue key, value = line.split("=", 1) folded = key.casefold() + if not _developer_variable(folded): + continue canonical = key.upper() if folded in {"path", "lib"} else key if folded == "lib": value = os.pathsep.join( @@ -43,14 +71,7 @@ def _command_environment(text: str) -> tuple[tuple[str, str], ...]: ) values[folded] = (canonical, value) - values.pop("__vscmd_preinit_path", None) - values.pop("path", None) - - inherited = {key.casefold(): value for key, value in os.environ.items()} - changed = ( - pair for folded, pair in values.items() if inherited.get(folded) != pair[1] - ) - return tuple(sorted(changed, key=lambda pair: pair[0].casefold())) + return tuple(sorted(values.values(), key=lambda pair: pair[0].casefold())) def _output(argv: list[str] | str, **options: str) -> str: diff --git a/build/driver.py b/build/driver.py index 1bc4ba7..08ec51f 100644 --- a/build/driver.py +++ b/build/driver.py @@ -6,6 +6,7 @@ from pathlib import Path import psutil +from core.clean import sweep_cas from core.graph import Graph, merge_graphs from core.host import require_runnable, runnable_architectures, verify_route from core.node import NodeFactory @@ -69,46 +70,86 @@ class Driver: """Own one repository-local runtime and compose command-specific graph families.""" def __init__(self, repository: Path, run_id: str, toolchain: MsvcToolchain, - *, jobs: int | None = None) -> None: + *, jobs: int | None = None, prune_cas: bool = False) -> None: self.repository = repository.resolve(strict=True) self.jobs = (psutil.cpu_count() or 1) if jobs is None else jobs require_positive_integers((self.jobs,), "jobs must be a positive integer") self.toolchain = toolchain self.runtime = BuildRuntime(self.repository, run_id) self._last_graph: Graph | None = None + self._prune_cas = prune_cas + self._prune_ready = False + + def _sweep_cas(self) -> None: + if self._prune_cas and self._prune_ready and self._last_graph is not None: + sweep_cas( + self.repository, self.runtime.live_uids(self._last_graph), + owned_run_id=self.runtime.owned_run_id, + ) async def _run(self, graph: Graph) -> tuple[Path, ...]: self._last_graph = graph await self.runtime.executor(graph).run() return tuple(self.runtime.store.paths_for(graph.node(name)).output for name in graph.targets) + async def _run_final(self, graph: Graph) -> tuple[Path, ...]: + self._prune_ready = True + return await self._run(graph) + async def _public( self, command: str, export_dir: Path | None, action: Callable[[], Awaitable[tuple[Path, ...]]], ) -> tuple[Path, ...]: self._last_graph = None - try: - outputs = await action() - except Exception as error: - if export_dir is not None and self._last_graph is not None: - failures = tuple(getattr(error, "failed_nodes", ())) - try: - export_results( - self._last_graph, self.runtime.store, export_dir, - command, "failed", failures=failures, - ) - except Exception as export_error: - raise ExceptionGroup( - f"{command} and result export failed", (error, export_error) - ) from None - raise - if export_dir is not None: - assert self._last_graph is not None - export_results( - self._last_graph, self.runtime.store, export_dir, - command, "success", failures=(), - ) - return outputs + self._prune_ready = False + async with self.runtime.session(): + try: + outputs = await action() + except Exception as error: + if export_dir is not None and self._last_graph is not None: + failures = tuple(getattr(error, "failed_nodes", ())) + try: + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "failed", failures=failures, + cache=self.runtime.cache_report(self._last_graph), + ) + except Exception as export_error: + raise ExceptionGroup( + f"{command} and result export failed", (error, export_error) + ) from None + try: + self._sweep_cas() + except Exception as prune_error: + raise ExceptionGroup( + f"{command} and cache pruning failed", (error, prune_error) + ) from None + raise + try: + self._sweep_cas() + except Exception as prune_error: + if export_dir is not None: + assert self._last_graph is not None + try: + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "failed", failures=(), + cache=self.runtime.cache_report(self._last_graph), + ) + except Exception as export_error: + raise ExceptionGroup( + "cache pruning and result export failed", + (prune_error, export_error), + ) from None + raise + if export_dir is not None: + assert self._last_graph is not None + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "success", failures=(), + cache=self.runtime.cache_report(self._last_graph), + ) + return outputs async def _staged(self, discovery: Graph, compose: Callable[..., Graph], **options: object) -> Graph: @@ -280,7 +321,7 @@ async def _package(self, architectures: tuple[str, ...], *, self.repository, audited, architectures=architectures, smoke_architectures=runnable_architectures(architectures), jobs=self.jobs, ) - await self._run(graph) + await self._run_final(graph) return package_outputs(self.repository, graph) async def package(self, architectures: tuple[str, ...] = ("x64",), *, @@ -420,7 +461,7 @@ async def _verify( windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs, ), )) - return await self._run(merge_graphs(*graphs)) + return await self._run_final(merge_graphs(*graphs)) async def verify_source(self, *, export_dir: Path | None = None) -> tuple[Path, ...]: async def operation() -> tuple[Path, ...]: @@ -429,7 +470,7 @@ async def operation() -> tuple[Path, ...]: common_source_checks(self.repository, tools, jobs=self.jobs), python_coverage_graph(self.repository), ) - return await self._run(graph) + return await self._run_final(graph) return await self._public("verify-source", export_dir, operation) diff --git a/build/graphs/analysis.py b/build/graphs/analysis.py index 908f578..9159fcd 100644 --- a/build/graphs/analysis.py +++ b/build/graphs/analysis.py @@ -21,6 +21,7 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1] _MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" +_HEADER_SUFFIXES = frozenset({".h", ".hh", ".hpp", ".hxx", ".inc", ".inl"}) @dataclass(frozen=True, slots=True) @@ -29,6 +30,7 @@ class MsbuildProject: path: Path inputs: tuple[str, ...] sources: tuple[Path, ...] + headers: tuple[Path, ...] def _relative(repository: Path, path: Path) -> str: @@ -64,20 +66,24 @@ def project_inventory( root / project_path(root, item.get("Include", ""), project) for item in document.iter(f"{_MSBUILD_NS}ClCompile") if item.get("Include") ) + headers = tuple( + root / project_path(root, item.get("Include", ""), project) for item in + document.iter(f"{_MSBUILD_NS}ClInclude") if item.get("Include") + ) projects.append(MsbuildProject( - project.stem, project, tuple(dict.fromkeys(inputs)), sources + project.stem, project, tuple(dict.fromkeys(inputs)), sources, headers )) return tuple(projects) -def _projects(repository: Path) -> tuple[tuple[str, Path, Path], ...]: +def _projects(repository: Path) -> tuple[tuple[str, Path, Path, tuple[Path, ...]], ...]: try: projects = project_inventory(repository, include_link_inputs=False) except ValueError as error: message = str(error).replace("unsupported project input", "unsupported ClCompile path", 1) raise ValueError(message) from error return tuple( - (project.name, project.path, source) for project in projects + (project.name, project.path, source, project.headers) for project in projects for source in project.sources ) @@ -107,6 +113,28 @@ def _project_files( return {path: (repository / path).read_bytes() for path in dict.fromkeys(inputs)} +def _stable_headers(source: Path, project_headers: tuple[Path, ...]) -> tuple[Path, ...]: + local_headers = tuple( + path for path in source.parent.iterdir() + if path.is_file() and path.suffix.casefold() in _HEADER_SUFFIXES + ) + return tuple(dict.fromkeys((*project_headers, *local_headers))) + + +def _discovery_files( + repository: Path, + project_name: str, + project: Path, + source: Path, + headers: tuple[Path, ...], +) -> dict[str, bytes]: + extra = tuple( + _relative(repository, path) + for path in _stable_headers(source, headers) + ) + return _project_files(repository, project_name, project, source, extra) + + def _compile_variables( toolchain: MsvcToolchain, project: Path, source: Path, restore_output: Path, configuration: str, platform: str, @@ -120,24 +148,11 @@ def _compile_variables( } -def _manifest_index( - repository: Path, requested: Mapping[str, str] -) -> dict[str, bytes]: - paths, manifests = BuildPaths(repository), {} - for name, uid in requested.items(): - cas = paths.cas(uid) - manifest = paths.require_confined( - cas.output / "dependencies.json", paths.cas_root - ) - if cas.touch.is_file() and not cas.touch.stat().st_size and manifest.is_file(): - manifests[name] = manifest.read_bytes() - return manifests - - def dependency_inputs( - repository: Path, restore_output: Path, source: Path, content: bytes + repository: Path, restore_output: Path, source: Path, content: bytes, + project_headers: tuple[Path, ...] = (), ) -> dict[str, bytes]: - """Validate one MSVC manifest and sign only project/package dependency bytes.""" + """Validate one manifest and sign its covered project/package dependency bytes.""" try: data = json.loads(content)["Data"] @@ -158,13 +173,25 @@ def dependency_inputs( files = {"compiler/dependencies.json": content} try: + source_root = (repository / "src").resolve(strict=True) + restore_root = restore_output.resolve(strict=False) + covered = { + path.resolve(strict=True) + for path in _stable_headers(source, project_headers) + } for candidate in dict.fromkeys((expected, *dependencies)): - if candidate.is_relative_to(repository / "src"): + resolved = candidate.resolve(strict=False) + if resolved.is_relative_to(source_root): path = candidate.resolve(strict=True) name = _relative(repository, path) - elif candidate.is_relative_to(restore_output): + if path != expected and path not in covered: + raise ValueError( + "first-party dependency is not covered by the stable header " + f"inventory for {source}: {name}" + ) + elif resolved.is_relative_to(restore_root): path = candidate.resolve(strict=True) - name = "vcpkg/" + path.relative_to(restore_output).as_posix() + name = "vcpkg/" + path.relative_to(restore_root).as_posix() else: continue files[name] = path.read_bytes() @@ -192,7 +219,9 @@ def project_build( manifest = manifests[name] except KeyError as error: raise ValueError(f"missing dependency manifest: {name}") from error - files.update(dependency_inputs(repository, restore_output, source, manifest)) + files.update(dependency_inputs( + repository, restore_output, source, manifest, project.headers + )) return factory.make( template, f"build-{project.name}-{architecture}-{qualifier}", "slot", variables, files=files, dependencies=tuple(dependencies), identity=identity, config=config, @@ -201,18 +230,15 @@ def project_build( def _dependency_node( repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, restore: Node, - unit: tuple[str, Path, Path], namespace: str, architecture: str, platform: str, - configuration: str | None, qualifier: str, previous: Mapping[str, bytes], + unit: tuple[str, Path, Path, tuple[Path, ...]], namespace: str, architecture: str, + platform: str, configuration: str | None, qualifier: str, ) -> Node: - project_name, project, source = unit + project_name, project, source, headers = unit name = dependency_node_name( repository, architecture, project_name, source, qualifier ) restore_output = BuildPaths(repository).cas(restore.uid).output - files = _project_files(repository, project_name, project, source) - prior = previous.get(name) - if prior is not None: - files.update(dependency_inputs(repository, restore_output, source, prior)) + files = _discovery_files(repository, project_name, project, source, headers) variables = _compile_variables( toolchain, project, source, restore_output, configuration or ("Release" if project_name == "leak-probe" else "Debug"), @@ -246,7 +272,7 @@ def dependency_discovery_slice( projects = tuple( unit for unit in _projects(root) if project_names is None or unit[0] in project_names ) - def create(previous: Mapping[str, bytes]) -> Graph: + def create() -> Graph: nodes, targets = [], [] for architecture in architectures: platform = _platform(architecture) @@ -260,17 +286,13 @@ def create(previous: Mapping[str, bytes]) -> Graph: continue node = _dependency_node( root, toolchain, factory, restore, unit, namespace, architecture, - platform, configuration, name_qualifier, previous, + platform, configuration, name_qualifier, ) nodes.append(node) targets.append(node.name) return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) - base = create({}) - previous = _manifest_index( - root, {name: base.node(name).uid for name in base.targets} - ) - return create(previous) if previous else base + return create() def _exact_identity(prefix: str, tool: ResolvedTool) -> dict[str, str]: @@ -305,7 +327,7 @@ def clang_dependency_discovery_slice( ) paths = BuildPaths(root) - def create(previous: Mapping[str, bytes]) -> Graph: + def create() -> Graph: nodes: list[Node] = [] targets: list[str] = [] for architecture in architectures: @@ -315,16 +337,13 @@ def create(previous: Mapping[str, bytes]) -> Graph: ) nodes.append(restore) restore_output = paths.cas(restore.uid).output - for project_name, project, source in projects: + for project_name, project, source, headers in projects: if project_name == "leak-probe" and architecture != "x64": continue name = dependency_node_name( root, architecture, project_name, source, name_qualifier ) - files = _project_files(root, project_name, project, source) - prior = previous.get(name) - if prior is not None: - files.update(dependency_inputs(root, restore_output, source, prior)) + files = _discovery_files(root, project_name, project, source, headers) capture = clang_factory.make( "clang-command.ps1", name.replace("discover-dependencies-", "capture-clang-command-", 1), @@ -363,11 +382,7 @@ def create(previous: Mapping[str, bytes]) -> Graph: targets.append(scan.name) return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) - base = create({}) - previous = _manifest_index( - root, {name: base.node(name).uid for name in base.targets} - ) - return create(previous) if previous else base + return create() def analysis_discovery_slice( @@ -397,11 +412,11 @@ def load_dependency_manifests(repository: Path, discovery: Graph) -> dict[str, b def _raw( repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, discovery: Node, - restore_output: Path, unit: tuple[str, Path, Path], backend: str, + restore_output: Path, unit: tuple[str, Path, Path, tuple[Path, ...]], backend: str, dependencies: Mapping[str, bytes], architecture: str, platform: str, ) -> Node: - project_name, project, source = unit + project_name, project, source, _headers = unit slug, template, extra = { "msvc": ("msvc", "msvc-analyze.ps1", ("build/ObserverNativeAnalysis.ruleset",)), "clang-tidy": ("tidy", "clang-tidy.ps1", (".clang-tidy",)), @@ -444,7 +459,7 @@ def output(node: Node) -> Path: restore = discovery.node(f"restore-vcpkg-{architecture}") normalized = [] for unit in projects: - project_name, _project, source = unit + project_name, _project, source, headers = unit if project_name == "leak-probe" and architecture != "x64": continue unit_name = _unit(root, source) @@ -455,7 +470,9 @@ def output(node: Node) -> Path: manifest = manifests[discovery_name] except KeyError as error: raise ValueError(f"missing dependency manifest: {discovery_name}") from error - dependencies = dependency_inputs(root, output(restore), source, manifest) + dependencies = dependency_inputs( + root, output(restore), source, manifest, headers + ) raw_nodes = tuple( (backend, _raw( root, toolchain, factory, discovered, output(restore), unit, backend, diff --git a/build/graphs/fuzz.py b/build/graphs/fuzz.py index 2a349d7..f7c0ee9 100644 --- a/build/graphs/fuzz.py +++ b/build/graphs/fuzz.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from pathlib import Path import re -import xml.etree.ElementTree as ET from core.graph import Graph, Node, Result from core.paths import BuildPaths @@ -15,13 +14,12 @@ clang_dependency_discovery_slice, dependency_inputs, dependency_node_name, + project_inventory, ) from graphs.common import recipe_factory, tool_environment _BUILD_ROOT = Path(__file__).resolve().parents[1] -_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" -_PREFIX = "$(RepositoryRoot)" _TARGETS = {"pickle": 262144, "renpy": 1048576, "rpgmaker": 1048576, "zanzarah": 1048576} FUZZ_TARGETS = tuple(_TARGETS) _COMMON = ( @@ -92,22 +90,28 @@ def _project_files( repository: Path, target: str, restore_output: Path, discovery: Graph, manifests: Mapping[str, bytes], ) -> tuple[dict[str, bytes], tuple[Node, ...]]: - project = repository / f"build/projects/fuzz-{target}.vcxproj" - paths = [repository / relative for relative in _COMMON] + [project] + try: + project = project_inventory( + repository, (f"fuzz-{target}",), include_link_inputs=False + )[0] + except ValueError as error: + message = str(error).replace( + "unsupported project input", "unsupported ClCompile path", 1 + ) + raise ValueError(message) from error + paths = [repository / relative for relative in _COMMON] + [project.path] files = {} dependencies = [] - for item in ET.parse(project).getroot().iter(f"{_NS}ClCompile"): - include = item.get("Include", "") - if not include.startswith(_PREFIX): - raise ValueError(f"unsupported ClCompile path in {project}: {include}") - source = (repository / include.removeprefix(_PREFIX)).resolve(strict=True) + for source in project.sources: name = dependency_node_name(repository, "x64", f"fuzz-{target}", source, "fuzz") dependencies.append(discovery.node(name)) try: manifest = manifests[name] except KeyError as error: raise ValueError(f"missing dependency manifest: {name}") from error - files.update(dependency_inputs(repository, restore_output, source, manifest)) + files.update(dependency_inputs( + repository, restore_output, source, manifest, project.headers + )) relative = (path.resolve(strict=True).relative_to(repository).as_posix() for path in paths) files.update({name: (repository / name).read_bytes() for name in dict.fromkeys(relative)}) return files, tuple(dependencies) diff --git a/build/main.py b/build/main.py index e79138c..4b360ae 100644 --- a/build/main.py +++ b/build/main.py @@ -69,6 +69,7 @@ def parse(value: str) -> int: "type": _integer(0, 1_073_741_824), "default": 0, }), "export_dir": (("-ExportDir", "--export-dir"), {"type": Path}), + "prune_cas": (("-PruneCas", "--prune-cas"), {"action": "store_true"}), "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "stale-work"), "default": "all"}), } @@ -147,7 +148,8 @@ async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) export_dir=args.export_dir, )), "verify-arch": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", - "leak_iterations", "leak_windows", "leak_tolerance", "export_dir"), + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir", + "prune_cas"), lambda driver, args, _toolchain: driver.verify_arch( args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, @@ -155,7 +157,8 @@ async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) export_dir=args.export_dir, )), "verify": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", - "leak_iterations", "leak_windows", "leak_tolerance", "export_dir"), + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir", + "prune_cas"), lambda driver, args, _toolchain: driver.verify( args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, @@ -182,7 +185,10 @@ def main(argv: Sequence[str] | None = None) -> int: parser.error("verify-arch requires exactly one architecture") args.run_nonce = _run_id() toolchain = discover_msvc_toolchain() - driver = BuildDriver(args.repository, args.run_nonce, toolchain, jobs=args.jobs) + driver = BuildDriver( + args.repository, args.run_nonce, toolchain, + jobs=args.jobs, prune_cas=getattr(args, "prune_cas", False), + ) outputs = asyncio.run(_COMMANDS[args.command][1](driver, args, toolchain)) if args.command in {"verify", "verify-arch"}: for item in verify_route(args.arch).deferred: diff --git a/build/tests/test_analysis_graph.py b/build/tests/test_analysis_graph.py index 17b23bf..543e842 100644 --- a/build/tests/test_analysis_graph.py +++ b/build/tests/test_analysis_graph.py @@ -2,7 +2,6 @@ from dataclasses import dataclass import json -import os from pathlib import Path import sys import tempfile @@ -13,10 +12,9 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.paths import BuildPaths, PathSafetyError # noqa: E402 +from core.paths import BuildPaths # noqa: E402 import graphs.analysis as analysis # noqa: E402 from graphs.analysis import ( # noqa: E402 - _manifest_index, analysis_discovery_slice, analysis_slice, load_dependency_manifests, @@ -382,6 +380,55 @@ def test_phase_two_signs_only_compiler_reported_project_and_package_files(self) package_changed.node(names["rpgmaker"]).uid, ) + def test_cross_directory_first_party_dependency_must_be_declared(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + shared = repository / "src/shared/topology.h" + shared.parent.mkdir() + shared.write_text("#pragma once\n", encoding="utf-8") + toolchain = self.toolchain(root) + discovery = analysis_discovery_slice(repository, toolchain) + renpy = "discover-dependencies-x64-renpy-modules.renpy.pickle" + rpgmaker = "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker" + manifests = { + renpy: self.manifest( + repository / "src/modules/renpy/pickle.cpp", shared + ), + rpgmaker: self.manifest( + repository / "src/modules/rpgmaker/rpgmaker.cpp" + ), + } + + with self.assertRaisesRegex( + ValueError, "first-party dependency is not covered.*src/shared/topology.h" + ): + analysis_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + ) + + project = repository / "build/projects/renpy.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "", + " \n", + ), + encoding="utf-8", + ) + declared_discovery = analysis_discovery_slice(repository, toolchain) + declared = analysis_slice( + repository, + toolchain, + discovery=declared_discovery, + manifests=manifests, + ) + + self.assertIn("analysis-x64", declared.targets) + def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -407,75 +454,120 @@ def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: self.assertEqual(loaded, expected) - def test_manifest_index_loads_only_the_exact_base_uid_without_scanning(self) -> None: + def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository = Path(temporary) / "repo" - repository.mkdir() - paths = BuildPaths(repository) - paths.prepare() - name = "discover-dependencies-x64-renpy-modules.renpy.pickle" - exact = paths.cas("1" * 32) - decoy = paths.cas("2" * 32) - for cas, content, timestamp in ( - (exact, b"exact", 100), - (decoy, b"newer decoy", 200), - ): - cas.output.mkdir(parents=True) - (cas.output / "dependencies.json").write_bytes(content) - cas.touch.touch() - os.utime(cas.touch, ns=(timestamp, timestamp)) - - with mock.patch.object( - Path, "glob", side_effect=AssertionError("CAS must not be scanned") - ): - loaded = _manifest_index(repository, {name: "1" * 32}) + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = analysis_discovery_slice(repository, self.toolchain(root)) - self.assertEqual(loaded, {name: b"exact"}) + with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"): + load_dependency_manifests(repository, discovery) - def test_manifest_index_ignores_missing_and_incomplete_exact_entries(self) -> None: + def test_cached_manifests_do_not_change_discovery_or_downstream_uids(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository = Path(temporary) / "repo" - repository.mkdir() - paths = BuildPaths(repository) - paths.prepare() - incomplete = paths.cas("1" * 32) - incomplete.output.mkdir(parents=True) - (incomplete.output / "dependencies.json").write_bytes(b"partial") + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + cold = analysis_discovery_slice(repository, toolchain) + manifests = {} + for target in cold.targets: + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + content = self.manifest(source) + manifests[target] = content + cas = BuildPaths(repository).cas(cold.node(target).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(content) + cas.touch.touch() - loaded = _manifest_index( + cold_downstream = analysis_slice( repository, - {"missing": "2" * 32, "incomplete": "1" * 32}, + toolchain, + discovery=cold, + manifests=manifests, + ) + warm = analysis_discovery_slice(repository, toolchain) + warm_downstream = analysis_slice( + repository, + toolchain, + discovery=warm, + manifests=manifests, ) - self.assertEqual(loaded, {}) + self.assertEqual( + {node.name: node.uid for node in cold.nodes}, + {node.name: node.uid for node in warm.nodes}, + ) + self.assertEqual( + {node.name: node.uid for node in cold_downstream.nodes}, + {node.name: node.uid for node in warm_downstream.nodes}, + ) - def test_manifest_index_rejects_reparse_manifest_leaf(self) -> None: + def test_partial_cached_manifests_do_not_mix_discovery_generations(self) -> None: with tempfile.TemporaryDirectory() as temporary: - repository = Path(temporary) / "repo" - repository.mkdir() - paths = BuildPaths(repository) - paths.prepare() - cas = paths.cas("1" * 32) + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + cold = analysis_discovery_slice(repository, toolchain) + target = cold.targets[0] + manifests = { + target: self.manifest( + repository / "src/modules/renpy/pickle.cpp", + repository / "src/modules/renpy/pickle.h", + ), + cold.targets[1]: self.manifest( + repository / "src/modules/rpgmaker/rpgmaker.cpp" + ), + } + cold_downstream = analysis_slice( + repository, + toolchain, + discovery=cold, + manifests=manifests, + ) + cas = BuildPaths(repository).cas(cold.node(target).uid) cas.output.mkdir(parents=True) - manifest = cas.output / "dependencies.json" - manifest.write_bytes(b"manifest") + (cas.output / "dependencies.json").write_bytes(manifests[target]) cas.touch.touch() - with mock.patch( - "core.paths._is_reparse", side_effect=lambda path: Path(path) == manifest - ), self.assertRaisesRegex(PathSafetyError, "reparse point"): - _manifest_index(repository, {"node": "1" * 32}) + partial = analysis_discovery_slice(repository, toolchain) + partial_downstream = analysis_slice( + repository, + toolchain, + discovery=partial, + manifests=manifests, + ) - def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: + self.assertEqual( + {node.name: node.uid for node in cold.nodes}, + {node.name: node.uid for node in partial.nodes}, + ) + self.assertEqual( + {node.name: node.uid for node in cold_downstream.nodes}, + {node.name: node.uid for node in partial_downstream.nodes}, + ) + + def test_header_content_invalidates_discovery_without_cached_manifest(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = self.repository(root / "repo") - discovery = analysis_discovery_slice(repository, self.toolchain(root)) + toolchain = self.toolchain(root) + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// dependency topology may have changed\n", + encoding="utf-8", + ) + after = analysis_discovery_slice(repository, toolchain) - with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"): - load_dependency_manifests(repository, discovery) + target = "discover-dependencies-x64-renpy-modules.renpy.pickle" + self.assertNotEqual(before.node(target).uid, after.node(target).uid) - def test_base_manifest_seeds_header_only_invalidation_without_directory_scan(self) -> None: + def test_cached_manifest_does_not_replace_stable_header_invalidation_or_scan_cas( + self, + ) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = self.repository(root / "repo") diff --git a/build/tests/test_clean.py b/build/tests/test_clean.py index 3dbf652..f7fd8b4 100644 --- a/build/tests/test_clean.py +++ b/build/tests/test_clean.py @@ -19,11 +19,14 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) +import core.clean as core_clean # noqa: E402 from core.clean import CleanError, clean, main # noqa: E402 from core.paths import BuildPaths, PathSafetyError # noqa: E402 UID = "0123456789abcdef0123456789abcdef" +LIVE_UID = "11111111111111111111111111111111" +ACTIVE_UID = "22222222222222222222222222222222" class CleanTests(unittest.TestCase): @@ -32,6 +35,182 @@ def repository(self, root: Path) -> tuple[Path, BuildPaths]: repository.mkdir(parents=True) return repository, BuildPaths(repository) + @staticmethod + def complete_cas(paths: BuildPaths, uid: str) -> Path: + entry = paths.cas(uid) + entry.output.mkdir(parents=True) + entry.log.touch() + entry.touch.touch() + return entry.entry + + def test_cas_sweep_uses_explicit_liveness_and_removes_incomplete_entries(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + live = self.complete_cas(paths, LIVE_UID) + inactive = self.complete_cas(paths, UID) + os.utime(paths.cas(LIVE_UID).touch, ns=(1, 1)) + os.utime(paths.cas(UID).touch, ns=(2_000_000_000, 2_000_000_000)) + incomplete = paths.cas(ACTIVE_UID) + incomplete.output.mkdir(parents=True) + incomplete.log.touch() + legacy = paths.cas_root / f"{UID}-legacy" + legacy.mkdir() + + removed = core_clean.sweep_cas(repository, {LIVE_UID}) + + self.assertEqual(removed, (inactive, incomplete.entry)) + self.assertTrue(live.is_dir()) + self.assertFalse(inactive.exists()) + self.assertFalse(incomplete.entry.exists()) + self.assertTrue(legacy.is_dir()) + + def test_cas_sweep_skips_active_nodes_and_holds_both_locks_while_removing(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + active = self.complete_cas(paths, ACTIVE_UID) + original_remove = shutil.rmtree + + def remove(target: Path) -> None: + for lock_path in (paths.coordination_lock(), paths.lock(UID)): + with self.assertRaises(Timeout), FileLock( + lock_path, timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + original_remove(target) + + active_lock = FileLock( + paths.lock(ACTIVE_UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + with active_lock, mock.patch( + "core.clean.shutil.rmtree", side_effect=remove + ): + self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,)) + + self.assertTrue(active.is_dir()) + + def test_cas_sweep_refuses_to_delete_while_another_run_lease_is_active(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + lease = FileLock( + paths.lease("active-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + + with lease, self.assertRaisesRegex(CleanError, "active build lease"): + core_clean.sweep_cas(repository, ()) + + self.assertTrue(inactive.is_dir()) + self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,)) + + def test_cas_sweep_ignores_only_the_explicit_owned_active_lease(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + owned = FileLock( + paths.lease("owned-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + + with owned: + self.assertEqual( + core_clean.sweep_cas( + repository, (), owned_run_id="owned-run" + ), + (inactive,), + ) + + inactive = self.complete_cas(paths, UID) + other = FileLock( + paths.lease("other-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + with owned, other, self.assertRaisesRegex(CleanError, "other-run"): + core_clean.sweep_cas(repository, (), owned_run_id="owned-run") + self.assertTrue(inactive.is_dir()) + + with self.assertRaisesRegex(CleanError, "owned run lease is not active"): + core_clean.sweep_cas(repository, (), owned_run_id="owned-run") + with self.assertRaisesRegex(CleanError, "owned run lease is missing"): + core_clean.sweep_cas(repository, (), owned_run_id="missing-run") + self.assertTrue(inactive.is_dir()) + + def test_cas_sweep_rechecks_paths_under_lock_and_rejects_unsafe_inputs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, paths = self.repository(root / "races") + paths.prepare() + inactive = self.complete_cas(paths, UID) + original_cas = BuildPaths.cas + calls = 0 + + def recheck(instance: BuildPaths, uid: str): + nonlocal calls + candidate = original_cas(instance, uid) + calls += 1 + if calls == 2: + with self.assertRaises(Timeout), FileLock( + paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + raise PathSafetyError("reparse point appeared") + return candidate + + with mock.patch.object(BuildPaths, "cas", autospec=True, side_effect=recheck), \ + self.assertRaisesRegex(PathSafetyError, "reparse point"): + core_clean.sweep_cas(repository, ()) + self.assertEqual(calls, 2) + self.assertTrue(inactive.is_dir()) + + with self.assertRaisesRegex(PathSafetyError, "UID"): + core_clean.sweep_cas(repository, {UID.upper()}) + self.assertTrue(inactive.is_dir()) + + malformed_lease = paths.locks_root / "run-.lease" + malformed_lease.touch() + with self.assertRaisesRegex(CleanError, "unexpected run lease"): + core_clean.sweep_cas(repository, ()) + self.assertTrue(inactive.is_dir()) + malformed_lease.unlink() + + with FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ), self.assertRaisesRegex(CleanError, "coordination lock"): + core_clean.sweep_cas(repository, ()) + self.assertTrue(inactive.is_dir()) + + reparse_repository, reparse = self.repository(root / "reparse") + reparse.prepare() + reparse_entry = self.complete_cas(reparse, UID) + with mock.patch( + "core.paths._is_reparse", + side_effect=lambda path: Path(path) == reparse_entry, + ), self.assertRaisesRegex(PathSafetyError, "reparse point"): + core_clean.sweep_cas(reparse_repository, ()) + self.assertTrue(reparse_entry.is_dir()) + + empty_repository, _empty = self.repository(root / "empty") + self.assertEqual(core_clean.sweep_cas(empty_repository, ()), ()) + + vanished_repository, vanished = self.repository(root / "vanished") + vanished.prepare() + with mock.patch("core.clean._validate", side_effect=(True, False)): + self.assertEqual(core_clean.sweep_cas(vanished_repository, ()), ()) + + no_cas_repository, no_cas = self.repository(root / "no-cas") + no_cas.output_root.mkdir() + no_cas.work_root.mkdir() + self.assertEqual(core_clean.sweep_cas(no_cas_repository, ()), ()) + def test_all_removes_only_exact_generated_output(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository, paths = self.repository(Path(temporary)) diff --git a/build/tests/test_driver.py b/build/tests/test_driver.py index afbd9fc..913f588 100644 --- a/build/tests/test_driver.py +++ b/build/tests/test_driver.py @@ -61,6 +61,7 @@ class FakeRuntime: def __init__(self, repository: Path, run_id: str) -> None: self.repository = repository self.run_id = run_id + self.session_active = False self.executed: list[Graph] = [] self.store = SimpleNamespace( paths_for=lambda current: SimpleNamespace( @@ -75,6 +76,30 @@ def executor(self, graph: Graph) -> FakeRuntime: async def run(self) -> None: return None + def session(self) -> FakeRuntime: + return self + + async def __aenter__(self) -> FakeRuntime: + self.session_active = True + return self + + async def __aexit__(self, *_exc: object) -> None: + self.session_active = False + + @property + def owned_run_id(self) -> str | None: + return self.run_id if self.session_active else None + + def cache_report(self, _graph: Graph | None = None) -> dict[str, object]: + return { + "schema": 1, + "summary": {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0}, + "nodes": [], + } + + def live_uids(self, graph: Graph) -> tuple[str, ...]: + return tuple(sorted(current.uid for current in graph.nodes)) + class DriverTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -89,8 +114,13 @@ def tearDown(self) -> None: self.runtime_patch.stop() self.temporary.cleanup() - def session(self, jobs: int | None = 4) -> driver.Driver: - return driver.Driver(self.repository, "run-1", self.toolchain, jobs=jobs) + def session( + self, jobs: int | None = 4, *, prune_cas: bool = False + ) -> driver.Driver: + return driver.Driver( + self.repository, "run-1", self.toolchain, + jobs=jobs, prune_cas=prune_cas, + ) def tool(self, name: str) -> ResolvedTool: path = self.repository / f"tools/{name}.exe" @@ -553,33 +583,155 @@ async def test_verify_source_runs_only_common_source_and_python_coverage(self) - export.assert_called_once_with( session.runtime.executed[0], session.runtime.store, self.repository / "evidence", "verify-source", "success", failures=(), + cache=session.runtime.cache_report(session.runtime.executed[0]), ) - async def test_failed_public_command_exports_structured_failures_before_reraising(self) -> None: - session = self.session() + async def test_enabled_cas_sweep_runs_before_successful_result_export(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + events: list[str] = [] + + def record(event: str) -> None: + self.assertTrue(session.runtime.session_active) + events.append(event) + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object( + driver, "export_results", side_effect=lambda *_args, **_kwargs: record("export") + ), + mock.patch.object( + driver, "sweep_cas", side_effect=lambda *_args, **_kwargs: record("sweep") + ) as sweep, + ): + await session.verify_source(export_dir=self.repository / "evidence") + + self.assertEqual(events, ["sweep", "export"]) + self.assertFalse(session.runtime.session_active) + graph = session.runtime.executed[0] + sweep.assert_called_once_with( + self.repository, session.runtime.live_uids(graph), owned_run_id="run-1" + ) + + async def test_cas_sweep_failure_exports_failed_status_before_reraising(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + events: list[str] = [] + + def fail_sweep(*_args, **_kwargs) -> None: + events.append("sweep") + raise maintenance + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep), + mock.patch.object( + driver, "export_results", + side_effect=lambda *_args, **_kwargs: events.append("export"), + ) as export, + self.assertRaises(RuntimeError) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception, maintenance) + self.assertEqual(events, ["sweep", "export"]) + self.assertEqual(export.call_args.args[4], "failed") + self.assertEqual(export.call_args.kwargs["failures"], ()) + + async def test_cas_sweep_failure_without_export_reraises_inside_session(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + + def fail_sweep(*_args, **_kwargs) -> None: + self.assertTrue(session.runtime.session_active) + raise maintenance + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep), + self.assertRaises(RuntimeError) as raised, + ): + await session.verify_source() + + self.assertIs(raised.exception, maintenance) + self.assertFalse(session.runtime.session_active) + + async def test_cas_sweep_and_failed_export_preserve_both_errors(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + export_failure = RuntimeError("export failed") + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=maintenance), + mock.patch.object(driver, "export_results", side_effect=export_failure), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], maintenance) + self.assertIs(raised.exception.exceptions[1], export_failure) + self.assertFalse(session.runtime.session_active) + + async def test_failed_public_command_exports_then_sweeps_before_reraising(self) -> None: + session = self.session(prune_cas=True) common = Graph((node("source"),), ("source",), {"slot": 4}) python = Graph((node("python"),), ("python",), {"slot": 4}) failure = ExceptionGroup("failed", (RuntimeError("expected"),)) failure.failed_nodes = ("source",) + live_uids = (common.nodes[0].uid,) + events: list[str] = [] async def fail() -> None: raise failure + def live(_graph: Graph) -> tuple[str, ...]: + events.append("live") + return live_uids + with ( mock.patch.object(driver, "discover_source_tools", return_value="tools"), mock.patch.object(driver, "common_source_checks", return_value=common), mock.patch.object(driver, "python_coverage_graph", return_value=python), mock.patch.object(session.runtime, "run", side_effect=fail), - mock.patch.object(driver, "export_results") as export, + mock.patch.object( + driver, "export_results", + side_effect=lambda *_args, **_kwargs: events.append("export"), + ) as export, + mock.patch.object(session.runtime, "live_uids", side_effect=live), + mock.patch.object( + driver, "sweep_cas", + side_effect=lambda *_args, **_kwargs: events.append("sweep"), + ) as sweep, self.assertRaises(ExceptionGroup) as raised, ): await session.verify_source(export_dir=self.repository / "failed-evidence") self.assertIs(raised.exception, failure) + self.assertEqual(events, ["export", "live", "sweep"]) graph = session.runtime.executed[0] export.assert_called_once_with( graph, session.runtime.store, self.repository / "failed-evidence", "verify-source", "failed", failures=("source",), + cache=session.runtime.cache_report(graph), + ) + sweep.assert_called_once_with( + self.repository, live_uids, owned_run_id="run-1" ) async def test_export_failure_preserves_the_original_execution_failure(self) -> None: @@ -607,6 +759,34 @@ async def fail() -> None: self.assertIs(raised.exception.exceptions[0], execution) self.assertRegex(str(raised.exception.exceptions[1]), "export") + async def test_failed_command_and_cas_sweep_failure_preserve_both_errors(self) -> None: + session = self.session(prune_cas=True) + graph = Graph((node("source"),), ("source",), {"slot": 4}) + execution = ExceptionGroup("failed", (RuntimeError("execution"),)) + execution.failed_nodes = ("source",) + maintenance = RuntimeError("prune") + + async def fail() -> None: + raise execution + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=graph), + mock.patch.object( + driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 4}), + ), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object(driver, "export_results"), + mock.patch.object(driver, "sweep_cas", side_effect=maintenance), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], execution) + self.assertIs(raised.exception.exceptions[1], maintenance) + self.assertIn("cache pruning failed", str(raised.exception)) + async def test_failure_before_graph_composition_does_not_publish_empty_evidence(self) -> None: session = self.session() with ( @@ -619,6 +799,33 @@ async def test_failure_before_graph_composition_does_not_publish_empty_evidence( await session.verify_source(export_dir=self.repository / "evidence") export.assert_not_called() + async def test_failure_after_discovery_export_does_not_prune_from_partial_graph(self) -> None: + session = self.session(prune_cas=True) + discovery = discovery_graph(("x64",)) + composition = RuntimeError("manifest composition failed") + + async def operation() -> tuple[Path, ...]: + await session._staged( + discovery, + mock.Mock(side_effect=composition), + ) + self.fail("composition failure must propagate") + + with ( + mock.patch.object(driver, "load_dependency_manifests", return_value={}), + mock.patch.object(driver, "export_results") as export, + mock.patch.object(driver, "sweep_cas") as sweep, + self.assertRaises(RuntimeError) as raised, + ): + await session._public( + "verify-arch", self.repository / "failed-evidence", operation + ) + + self.assertIs(raised.exception, composition) + self.assertEqual(session.runtime.executed, [discovery]) + self.assertEqual(export.call_args.args[4], "failed") + sweep.assert_not_called() + async def test_verify_arch_omits_common_and_nonrunnable_specialists(self) -> None: session = self.session() route = SimpleNamespace( diff --git a/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py index 4e0b9aa..b38634f 100644 --- a/build/tests/test_graph_main_coverage.py +++ b/build/tests/test_graph_main_coverage.py @@ -93,7 +93,9 @@ def test_compiler_manifest_accepts_only_exact_relevant_dependency_bytes(self) -> } ).encode() - files = analysis.dependency_inputs(repository, package_root, source, content) + files = analysis.dependency_inputs( + repository, package_root, source, content, (first_party,) + ) self.assertEqual( set(files), diff --git a/build/tests/test_instrumented_graph.py b/build/tests/test_instrumented_graph.py index 1cc61b1..5090ad0 100644 --- a/build/tests/test_instrumented_graph.py +++ b/build/tests/test_instrumented_graph.py @@ -21,7 +21,6 @@ ) from graphs.analysis import ( # noqa: E402 clang_dependency_discovery_slice, - dependency_node_name, ) @@ -494,57 +493,209 @@ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumer before.node(name).uid, toolchain_changed.node(name).uid, name ) - def test_clang_discovery_skips_unsupported_leak_arch_and_signs_prior_manifest(self) -> None: + def test_clang_discovery_and_build_uids_ignore_manifest_cache_state(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = self.repository(root / "repo") toolchain = self.toolchain(root) - source = repository / "src/leak-probe.cpp" - name = dependency_node_name( - repository, "x64", "leak-probe", source, "coverage" + runtime = self.llvm_runtime(root) + variants = ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, {"hash": "runtime"} + ), ) - manifest = json.dumps( - { - "Data": { - "Source": str(source.resolve()), - "Includes": [str((repository / "src/tests.h").resolve())], - } - } - ).encode() - with mock.patch("graphs.analysis._manifest_index", return_value={name: manifest}): - discovery = clang_dependency_discovery_slice( - repository, - toolchain, - project_names=("leak-probe",), - configuration="Coverage", - name_qualifier="coverage", - architectures=("x86", "x64"), - ) - with mock.patch("graphs.analysis._manifest_index", return_value={}): - without_prior = clang_dependency_discovery_slice( + cold = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, cold) + cold_build = instrumented_build_slice( + repository, + toolchain, + discovery=cold, + manifests=manifests, + variants=variants, + ) + paths = BuildPaths(repository) + first = cold.targets[0] + cas = paths.cas(cold.node(first).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(manifests[first]) + cas.touch.touch() + partial = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + partial_build = instrumented_build_slice( + repository, + toolchain, + discovery=partial, + manifests=manifests, + variants=variants, + ) + for target in cold.targets[1:]: + cas = paths.cas(cold.node(target).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(manifests[target]) + cas.touch.touch() + full = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + full_build = instrumented_build_slice( + repository, + toolchain, + discovery=full, + manifests=manifests, + variants=variants, + ) + + expected_discovery = {node.name: node.uid for node in cold.nodes} + expected_build = {node.name: node.uid for node in cold_build.nodes} + self.assertEqual( + expected_discovery, + {node.name: node.uid for node in partial.nodes}, + ) + self.assertEqual( + expected_discovery, + {node.name: node.uid for node in full.nodes}, + ) + self.assertEqual( + expected_build, + {node.name: node.uid for node in partial_build.nodes}, + ) + self.assertEqual( + expected_build, + {node.name: node.uid for node in full_build.nodes}, + ) + + def test_clang_discovery_signs_stable_header_coverage(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + before = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("renpy",), + configuration="Coverage", + name_qualifier="coverage", + ) + (repository / "src/renpy.h").write_text( + "#pragma once\n// dependency topology may have changed\n", + encoding="utf-8", + ) + after = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("renpy",), + configuration="Coverage", + name_qualifier="coverage", + ) + + target = before.targets[0] + self.assertNotEqual(before.node(target).uid, after.node(target).uid) + before_capture = before.node(before.node(target).inputs[0]) + after_capture = after.node(after.node(target).inputs[0]) + self.assertNotEqual(before_capture.uid, after_capture.uid) + + def test_instrumented_build_requires_declared_cross_directory_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + shared = repository / "src/shared/topology.h" + shared.parent.mkdir() + shared.write_text("#pragma once\n", encoding="utf-8") + toolchain = self.toolchain(root) + variants = (InstrumentedVariant("coverage", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, discovery) + for target in discovery.targets: + if "-renpy-" in target: + manifests[target] = json.dumps( + { + "Data": { + "Source": str( + (repository / "src/renpy.cpp").resolve() + ), + "Includes": [str(shared.resolve())], + } + } + ).encode() + + with self.assertRaisesRegex( + ValueError, "first-party dependency is not covered.*src/shared/topology.h" + ): + instrumented_build_slice( repository, toolchain, - project_names=("leak-probe",), - configuration="Coverage", - name_qualifier="coverage", - architectures=("x86", "x64"), + discovery=discovery, + manifests=manifests, + variants=variants, ) - asan = (InstrumentedVariant("asan", "x64"),) - asan_discovery = instrumented_dependency_discovery_slice( - repository, toolchain, variants=asan + + project = repository / "build/projects/renpy.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "", + " \n", + ), + encoding="utf-8", ) - instrumented_build_slice( + declared_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + declared = instrumented_build_slice( repository, toolchain, - discovery=asan_discovery, - manifests=self.manifests(repository, asan_discovery), - variants=asan, + discovery=declared_discovery, + manifests=manifests, + variants=variants, ) - self.assertEqual(discovery.targets, (name,)) - capture = discovery.node(discovery.node(name).inputs[0]) - previous_capture = without_prior.node(without_prior.node(name).inputs[0]) - self.assertNotEqual(capture.uid, previous_capture.uid) + self.assertIn("build-renpy-x64-coverage", declared.targets) + + def test_clang_discovery_skips_unsupported_leak_architecture(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = clang_dependency_discovery_slice( + repository, + self.toolchain(root), + project_names=("leak-probe",), + configuration="Coverage", + name_qualifier="coverage", + architectures=("x86", "x64"), + ) + + self.assertEqual(len(discovery.targets), 1) + self.assertIn("-x64-coverage-leak-probe-", discovery.targets[0]) + + def test_asan_only_build_does_not_require_clang(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + variants = (InstrumentedVariant("asan", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + + with mock.patch( + "graphs.instrumented.resolve_llvm", + side_effect=AssertionError("ASan must stay on the MSVC path"), + ): + graph = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=self.manifests(repository, discovery), + variants=variants, + ) + + self.assertEqual(len(graph.targets), 4) + self.assertTrue(all(name.endswith("-x64-asan") for name in graph.targets)) if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/build/tests/test_main.py b/build/tests/test_main.py index e02e6f8..3a81305 100644 --- a/build/tests/test_main.py +++ b/build/tests/test_main.py @@ -103,7 +103,10 @@ def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> with self.subTest(command=method): result = self.invoke([command_names.get(method, method), "-Repository", str(repository), *options]) instance = FakeDriver.instances[-1] - self.assertEqual(instance.constructor, ((repository, "run-id", mock.ANY), {"jobs": None})) + self.assertEqual(instance.constructor, ( + (repository, "run-id", mock.ANY), + {"jobs": None, "prune_cas": False}, + )) self.assertEqual(instance.calls, [(method, positional, keywords)]) self.assertEqual(result.result, 0) self.assertEqual(result.stdout.strip(), str(Path("out") / method)) @@ -208,6 +211,21 @@ async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]: main.main(["verify", "-Arch", "arm64"]) self.assertEqual(stdout.getvalue(), "") + def test_verify_prune_cas_flag_enables_driver_sweep(self) -> None: + result = self.invoke([ + "verify-arch", "-Arch", "x64", "-PruneCas", + ]) + + instance = FakeDriver.instances[-1] + self.assertEqual( + instance.constructor, + ((Path(__file__).parents[2], "run-id", mock.ANY), { + "jobs": None, + "prune_cas": True, + }), + ) + self.assertEqual(result.result, 0) + def test_help_and_removed_skip_restore_contract(self) -> None: for argv in ([], ["help"]): with self.subTest(argv=argv), redirect_stdout(StringIO()) as stdout: diff --git a/build/tests/test_result_export.py b/build/tests/test_result_export.py index f7fec3b..b111cf5 100644 --- a/build/tests/test_result_export.py +++ b/build/tests/test_result_export.py @@ -175,6 +175,35 @@ def test_failed_export_includes_existing_logs_and_only_complete_results(self) -> ["logs/ready.log", "logs/failed.log"], ) + def test_root_manifest_publishes_cache_identity_report(self) -> None: + producer = node("cached") + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + cache = { + "schema": 1, + "summary": {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0}, + "nodes": [{ + "duration_ms": 0, + "name": producer.name, + "state": "hit", + "uid": producer.uid, + }], + } + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + self.prepare(store, producer) + destination = root / "export" + + export_results( + graph, store, destination, "verify", "success", cache=cache + ) + manifest = json.loads( + (destination / "manifest.json").read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["cache"], cache) + def test_existing_destination_missing_result_and_path_collisions_are_rejected(self) -> None: cases = ( "existing", diff --git a/build/tests/test_runtime.py b/build/tests/test_runtime.py index c023e6b..38f5175 100644 --- a/build/tests/test_runtime.py +++ b/build/tests/test_runtime.py @@ -15,6 +15,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) +from core.clean import CleanError, clean # noqa: E402 from core.graph import Command, Graph, Node # noqa: E402 from core.runtime import BuildRuntime, ProcessFailed # noqa: E402 @@ -22,7 +23,12 @@ RUN_ID = "20260801-runtime" -def node(name: str = "analyze-renpy.pickle", *, env: tuple[tuple[str, str], ...] = ()) -> Node: +def node( + name: str = "analyze-renpy.pickle", + *, + env: tuple[tuple[str, str], ...] = (), + inputs: tuple[str, ...] = (), +) -> Node: return Node( name=name, uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), @@ -33,6 +39,7 @@ def node(name: str = "analyze-renpy.pickle", *, env: tuple[tuple[str, str], ...] cwd=r"C:\repo\source", stdin=b"exact recipe\r\n", ), + inputs=inputs, ) @@ -174,6 +181,111 @@ async def test_executor_runs_and_publishes_success(self) -> None: self.assertTrue(runtime.is_complete(current)) self.assertEqual(runtime.store.paths_for(current).touch.stat().st_size, 0) + async def test_cache_report_distinguishes_executed_and_restored_nodes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + + cold_runner = FakeRunner() + cold = BuildRuntime(repository, "cold-run", process_runner=cold_runner) + await cold.executor(graph).run() + cold_report = cold.cache_report() + + warm_runner = FakeRunner() + warm = BuildRuntime(repository, "warm-run", process_runner=warm_runner) + await warm.executor(graph).run() + warm_report = warm.cache_report() + + self.assertEqual(cold_report["schema"], 1) + self.assertEqual( + cold_report["summary"], + {"executed": 1, "failed": 0, "hit": 0, "incomplete": 0}, + ) + self.assertEqual(cold_report["nodes"][0]["name"], current.name) + self.assertEqual(cold_report["nodes"][0]["uid"], current.uid) + self.assertEqual(cold_report["nodes"][0]["state"], "executed") + self.assertGreaterEqual(cold_report["nodes"][0]["duration_ms"], 0) + self.assertEqual(cold.live_uids(), (current.uid,)) + + self.assertEqual( + warm_report["summary"], + {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0}, + ) + self.assertEqual(warm_report["nodes"], [ + { + "duration_ms": 0, + "name": current.name, + "state": "hit", + "uid": current.uid, + } + ]) + self.assertEqual(warm.live_uids(), (current.uid,)) + self.assertEqual(len(cold_runner.calls), 1) + self.assertEqual(warm_runner.calls, []) + + async def test_warm_cached_target_retains_and_reports_its_complete_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + dependency = node("dependency") + target = node("target", inputs=(dependency.name,)) + graph = Graph((dependency, target), (target.name,), {"cpu": 1}) + + cold = BuildRuntime(repository, "cold-run", process_runner=FakeRunner()) + await cold.executor(graph).run() + warm = BuildRuntime(repository, "warm-run", process_runner=FakeRunner()) + await warm.executor(graph).run() + + self.assertEqual( + warm.live_uids(graph), tuple(sorted((dependency.uid, target.uid))) + ) + self.assertEqual( + warm.cache_report(graph)["summary"], + {"executed": 0, "failed": 0, "hit": 2, "incomplete": 0}, + ) + + async def test_cache_report_excludes_failed_nodes_from_live_uids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + runtime = BuildRuntime( + repository, "failed-run", process_runner=FakeRunner(23) + ) + + with self.assertRaises(ExceptionGroup): + await runtime.executor(graph).run() + + self.assertEqual(runtime.cache_report()["summary"], { + "executed": 0, + "failed": 1, + "hit": 0, + "incomplete": 0, + }) + self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "failed") + self.assertEqual(runtime.live_uids(), ()) + + async def test_cache_report_marks_observed_unpublished_nodes_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, "pending-run", process_runner=FakeRunner()) + current = node() + + self.assertFalse(runtime.is_complete(current)) + + self.assertEqual(runtime.cache_report()["summary"], { + "executed": 0, + "failed": 0, + "hit": 0, + "incomplete": 1, + }) + self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "incomplete") + self.assertEqual(runtime.live_uids(), ()) + async def test_executor_holds_per_run_lease_without_serializing_distinct_runs(self) -> None: class ConcurrentRunner: entered = 0 @@ -219,6 +331,38 @@ async def run(self, _command: Command, *, log) -> int: preserve_lock_file=True): pass + async def test_session_reuses_one_lease_across_executors_and_blocks_clean(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + graphs = tuple( + Graph((current,), (current.name,), {"cpu": 1}) + for current in (node("discovery"), node("final")) + ) + + async with runtime.session(): + self.assertEqual(runtime.owned_run_id, RUN_ID) + with self.assertRaisesRegex(RuntimeError, "already active"): + async with runtime.session(): + pass + await runtime.executor(graphs[0]).run() + with self.assertRaises(CleanError): + await asyncio.to_thread(clean, repository) + await runtime.executor(graphs[1]).run() + with self.assertRaises(Timeout), FileLock( + runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + + self.assertIsNone(runtime.owned_run_id) + with FileLock( + runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + async def test_nonzero_exit_leaves_entry_incomplete_without_marker(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo" diff --git a/build/tests/test_toolchain.py b/build/tests/test_toolchain.py index 7e3a178..b29f31c 100644 --- a/build/tests/test_toolchain.py +++ b/build/tests/test_toolchain.py @@ -12,7 +12,7 @@ BUILD_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BUILD_ROOT)) -from core.toolchain import discover_msvc_toolchain # noqa: E402 +from core.toolchain import _command_environment, discover_msvc_toolchain # noqa: E402 def executable(path: Path) -> Path: @@ -22,6 +22,42 @@ def executable(path: Path) -> Path: class MsvcToolchainTests(unittest.TestCase): + def test_command_environment_is_stable_when_canonical_values_are_inherited(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + include = root / "include" + lib = root / "lib" + libpath = root / "references" + for directory in (include, lib, libpath): + directory.mkdir() + + canonical = { + "INCLUDE": str(include), + "LIB": str(lib), + "LIBPATH": str(libpath), + "VCToolsVersion": "14.44.35207", + "VSCMD_VER": "17.14.15", + "WindowsSDKVersion": "10.0.26100.0\\", + } + captured = "\r\n".join( + ( + *(f"{key}={value}" for key, value in canonical.items()), + "Path=toolchain;host", + "__VSCMD_PREINIT_PATH=host", + "GITHUB_SHA=unchanged", + "UNRELATED=unchanged", + ) + ) + inherited = {"GITHUB_SHA": "unchanged", "UNRELATED": "unchanged"} + + with mock.patch.dict(os.environ, inherited, clear=True): + absent = _command_environment(captured) + with mock.patch.dict(os.environ, inherited | canonical, clear=True): + already_equal = _command_environment(captured) + + self.assertEqual(dict(absent), canonical) + self.assertEqual(already_equal, absent) + def test_discovers_x64_tools_and_canonical_command_environment(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py index 88fce84..ebe8ae5 100644 --- a/build/tests/test_workflow_contract.py +++ b/build/tests/test_workflow_contract.py @@ -29,6 +29,7 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: ) self.assertEqual(workflow.count("./build.ps1 verify-source"), 1) self.assertEqual(workflow.count("./build.ps1 verify-arch"), 1) + self.assertEqual(workflow.count("-PruneCas"), 1) self.assertNotIn("./build.ps1 verify -Arch", workflow) self.assertNotIn("continue-on-error", workflow) @@ -52,14 +53,23 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: self.assertIn("/packages", workflow) self.assertNotIn("if-no-files-found: ignore", workflow) self.assertIn("permissions:\n contents: read", workflow) - self.assertEqual(workflow.count("path: out/cas"), 1) + self.assertEqual(workflow.count("path: out/cas"), 2) + self.assertIn("id: restore-build-cas", workflow) + self.assertIn("uses: actions/cache/restore@", workflow) + self.assertIn("uses: actions/cache/save@", workflow) self.assertIn( - "key: cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-" - "${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}", + "key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-" + "${{ github.run_attempt }}", workflow, ) - self.assertIn("cas-v1-${{ steps.runner-image.outputs.identity }}-${{ matrix.job }}-", workflow) - self.assertIn("save-always: true", workflow) + self.assertIn("cas-v1-${{ matrix.job }}-${{ github.sha }}-", workflow) + self.assertIn("cas-v1-${{ matrix.job }}-", workflow) + self.assertNotIn("cas-v1-${{ steps.runner-image.outputs.identity }}", workflow) + self.assertIn("if: always() && matrix.job != 'source'", workflow) + self.assertIn( + "key: ${{ steps.restore-build-cas.outputs.cache-primary-key }}", workflow + ) + self.assertNotIn("save-always", workflow) self.assertNotIn("out/work", workflow) self.assertNotIn("contents: write", workflow) self.assertNotIn("download-artifact", workflow) @@ -73,6 +83,8 @@ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: "actions/checkout": "d23441a48e516b6c34aea4fa41551a30e30af803", "astral-sh/setup-uv": "08807647e7069bb48b6ef5acd8ec9567f424441b", "actions/cache": "caa296126883cff596d87d8935842f9db880ef25", + "actions/cache/restore": "caa296126883cff596d87d8935842f9db880ef25", + "actions/cache/save": "caa296126883cff596d87d8935842f9db880ef25", "actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", }, ) diff --git a/docs/build-system.md b/docs/build-system.md index f4fc27c..135bd37 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -49,13 +49,15 @@ PowerShell-specific quoting is centralized instead of repeated in every leaf. Canonical MD5 covers the fully rendered recipe, descriptor/argv data, declared input bytes and paths, dependency UIDs, toolchain/platform identity, and the executor schema. MD5 is a fast local content identity, not a cryptographic trust -boundary for remote artifacts. A CAS hit requires both the expected entry and its `touch` marker; failed or cancelled -work cannot publish the marker. +boundary for remote artifacts. A CAS hit requires a canonical entry directory, its `out` directory, regular `log.txt`, +and a zero-byte regular `touch` publication marker; failed or cancelled work cannot publish that marker. -`filelock` coordinates node publication and clean operations between cooperating local processes. Mutable paths are -confined below the exact repository output root and existing reparse points are rejected. `psutil` launches and -observes processes; a Windows Job Object terminates the complete descendant tree on failure or cancellation. These are -explicit local-build guarantees, not a claim of hostile-process isolation. +`filelock` coordinates node publication and clean operations between cooperating local processes. A public command +holds one run lease across dependency discovery, all executor phases, manifest reads, telemetry, pruning, and result +export; standalone executor use acquires the same lease implicitly. Mutable paths are confined below the exact +repository output root and existing reparse points are rejected. `psutil` launches and observes processes; a Windows +Job Object terminates the complete descendant tree on failure or cancellation. These are explicit local-build +guarantees, not a claim of hostile-process isolation. ## IX-aligned build model @@ -84,12 +86,17 @@ linking is explicitly outside this stage. downloaded artifact must be authenticated separately. - The UID covers the rendered recipe, declared input paths and bytes, dependency UIDs, semantic configuration, and a normalized toolchain fingerprint. -- Absolute checkout/CAS/export paths, `GITHUB_*`, commit and PR ids, run timestamps, log destinations, `Jobs`, pool - capacities, and scheduler order do not affect the UID. +- Dependency discovery signs every project-declared header plus header-like files beside the translation unit. The + compiler manifest is then checked fail-closed: any other first-party header dependency must be added to the + project's `` inventory before downstream graph construction can continue. +- Absolute checkout/CAS/export paths, `GITHUB_*`, commit and PR ids, log destinations, `Jobs`, pool capacities, and + scheduler order do not affect the UID. The explicit run nonce is the exception: it intentionally changes fuzz, + leak, and opt-in corpus test identities; the default CLI nonce is based on time and process id. - Change the physical successful-entry key to `out/cas/`. The readable node name remains in graph diagnostics, logs, and exported manifests instead of being duplicated in the CAS directory name. -- Keep mutable scratch, locks, leases, failed work, and incomplete-entry quarantine below `out/work`. There is no - permanent IX-style trash directory: quarantine is recoverable during the run and stale work is removed by the +- Keep mutable scratch, locks, leases, and incomplete-entry quarantine below `out/work`. A failed node may first leave + a markerless directory and log in `out/cas`; a later demand moves it to per-run quarantine before rebuilding. There + is no permanent IX-style trash directory: quarantine is recoverable during the run and stale work is removed by the existing safe cleanup contract. ### Execution @@ -114,7 +121,11 @@ separate `packages/manifest.json`, whose paths are relative to that bundle, so C without publishing release ZIPs from pull requests. Manifests are written after their files and record command status, result ids, relative paths, producer UIDs, sizes, and SHA-256 digests. Diagnostics produced before a later gate failure are exported; release packages are exported only after their complete package gates pass. The export destination is -not part of recipe identity. +not part of recipe identity. The root manifest also records graph nodes and their UIDs as `hit`, `executed`, `failed`, +or `incomplete`. Durations are measured for executed and failed nodes; hits and incomplete nodes report zero. A hit is +any valid pre-existing CAS entry, whether restored by CI or already local. `incomplete` means the graph node neither +hit nor completed in this invocation, commonly because it was blocked; it is not an inventory of markerless CAS +directories. Telemetry is captured before pruning and does not report removed entries. ## Supported commands @@ -146,6 +157,12 @@ format target. Without `-ExportDir`, commands may print their internal target paths for local diagnostics. Stable consumers use the typed export boundary; mutable intermediates and locks remain below `out/work`. +`verify` and `verify-arch` accept `-PruneCas`. The option is intended for bounded cache generations such as CI. On a +successful command it retains every complete node UID in the full graph, including unvisited dependencies of a cached +target, and removes unlocked non-live canonical directories before publishing success evidence. Without `-ExportDir` +the same success-path sweep runs without a manifest; a failed command without export keeps its internal failure data. +Normal local commands keep prior successful nodes for fast switching between targets and configurations. + `verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A @@ -153,10 +170,10 @@ non-native runtime check is reported explicitly as deferred rather than falsely designed to be runnable locally on the supported Windows host. The verify fuzz work covers all four format targets; `-FuzzSeconds` controls each bounded run. -All graph families are merged into one executor. Ready nodes from builds, tests, analyzers, coverage, sanitizers, -fuzzing, leak checks, audit, and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the -shared global capacity. The implementation has no unmeasured UMDH or BinSkim limits encoded as -scheduler policy. +Verification has two executor phases: a merged dependency-discovery graph, then one merged post-discovery graph. +Within the second phase, ready nodes from builds, tests, analyzers, coverage, sanitizers, fuzzing, leak checks, audit, +and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the shared global capacity. The +implementation has no unmeasured UMDH or BinSkim limits encoded as scheduler policy. ## Configurations @@ -282,20 +299,36 @@ Pull requests use the same gates and thresholds with shorter explicit bounded-wo hidden `pr`/`full` gate composition and no manual profile; changing a bounded duration never removes a target or weakens the 100% coverage and zero-finding gates. -The workflow caches dependency transport data and completed content-addressed build nodes. CAS caches are isolated by -hosted-runner image and architecture job, restored only within GitHub's branch/ref cache scope, and never shared with -the source job. Node identities still cover recipes, declared inputs, dependency identities, configuration, runtime, -and toolchain content; fuzz and leak nodes include a run nonce. An absent or evicted cache therefore changes only -latency. The workflow never caches `out/work`, packages, or an installed vcpkg tree. - -Each job uploads its `-ExportDir` with `if: always()` so reports from completed independent branches survive a later -failure. Pull-request evidence is retained for seven days; `master` evidence for thirty days. Release ZIPs and PDBs are -uploaded only from successful `master` jobs. Actions are pinned to full commit SHAs, permissions default to +The workflow caches dependency transport data and the `out/cas` content-addressed store. CAS caches are isolated by +architecture job, restored only within GitHub's branch/ref cache scope, and never shared with the source job. Their +outer restore prefixes deliberately span hosted-runner image revisions: node identities cover recipes, declared +inputs, dependency identities, configuration, runtime, and toolchain content, so incompatible nodes miss without +requiring a coarse image key. Each run and attempt has a unique generation key. Separate restore and save steps retain +the store after a later gate fails. Only canonical complete entries can hit: a demanded incomplete entry is +quarantined and rebuilt. Fuzz, leak, and opt-in corpus nodes include a run nonce. An absent or evicted cache therefore +changes only latency. The workflow never caches `out/work`, an installed vcpkg tree, or the separate +`ExportDir/packages` bundle; package-node outputs can still live inside the CAS cache. + +Architecture jobs pass `-PruneCas`. On success, telemetry is captured, mark-and-sweep removes unlocked canonical +directories not named by the command graph's live UID set, and then the success manifest is published. On execution +failure inside the complete final graph, available failure evidence is published before pruning so failed logs survive. +If manifest loading or final graph composition fails after discovery, evidence for the partial graph is exported but +pruning is skipped because that graph is not a complete liveness boundary. The workflow then saves a new immutable +GitHub cache generation. Locked and noncanonical entries are preserved; no access time or wall-clock age participates +in liveness. An early graph/export/prune failure can still reach the `always()` save step with an unpruned store. GitHub +eventually evicts older whole generations according to its cache retention policy; unlocked canonical stale entries do +not propagate after a successful prune. + +Each job attempts to upload the manifest, reports, and logs from its `-ExportDir` with `if: always()` so evidence from +completed independent branches survives a later failure; a failure after the verification step starts but before +manifest creation is reported as missing evidence. Pull-request evidence is retained for seven days; `master` +evidence for thirty days. The separate packages +subtree is uploaded only from successful `master` jobs. Actions are pinned to full commit SHAs, permissions default to `contents: read`, untrusted pull requests receive no secrets, and `pull_request_target` is forbidden. -The workflow first runs `doctor`, then exactly one public verification command. It always uploads the self-contained -evidence bundle and uploads the separate package bundle only from successful `master` architecture jobs. Branch -protection requires `source`, `x86`, `x64`, and `arm64-cross`. +The workflow first runs `doctor`, then exactly one public verification command. It uploads the self-contained evidence +bundle when verification produced one and uploads the separate package bundle only from successful `master` +architecture jobs. Branch protection requires `source`, `x86`, `x64`, and `arm64-cross`. The hosted x64 job selects UMDH from the serviced Windows 10 SDK 2004 line explicitly. This avoids the documented allocation-stack capture defect in the UMDH shipped with Windows 11 SDKs without changing the locally runnable leak @@ -420,13 +453,21 @@ regression input after triage. ## Output and cleanup -`out/cas` contains immutable successful node results. `out/work` contains in-flight scratch space, failed-run evidence, -and `.locks`. Successful node scratch is removed immediately. Each active execution holds a run lease; `clean` takes a -coordination lock and refuses to race active runs or node publishers. +`out/cas` contains immutable successful node results plus markerless directories left by failed or cancelled nodes. +`out/work` contains in-flight scratch space, quarantined incomplete entries, failed-run scratch, and `.locks`. +Successful node scratch is removed immediately. Each active execution holds a run lease; `clean` takes a coordination +lock and refuses to race active runs or node publishers. `clean -CleanMode stale-work` removes only inactive scratch. `clean -CleanMode all` removes CAS entries, completed run -data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate that every target -is the exact repository `out` layout and reject reparse points or unknown entries. +data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate the exact +repository `out` layout and reject reparse points, unsafe types, and unexpected top-level/work/lock entries. + +CI uses the narrower `-PruneCas` contract instead of `clean`: complete UIDs from the full current command graph are the +mark set, and canonical CAS directories outside that set are candidates for removal whether complete or markerless. +The sweep takes the global coordination lock, refuses to run while another run lease is active, takes each candidate +node lock, and revalidates its confined directory under that lock. The caller's explicitly identified, verified-active +session lease is the sole exception. The sweep skips locked candidates, preserves noncanonical entries, and rejects an +unsafe layout. After the portable parser core is complete, revisit a separate local WSL2 workflow for Linux-only sanitizers and test-quality experiments. It is not part of the current build graph; shipping modules remain Windows/MSVC artifacts. From 9b52226a91d086e57f524ceeb1dae4fcd5197f79 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 2 Aug 2026 22:13:50 +1000 Subject: [PATCH 17/17] Declare test project header dependencies --- build/projects/tests.vcxproj | 3 ++ build/tests/test_graph_main_coverage.py | 40 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/build/projects/tests.vcxproj b/build/projects/tests.vcxproj index 3cc6a25..be209d5 100644 --- a/build/projects/tests.vcxproj +++ b/build/projects/tests.vcxproj @@ -52,12 +52,15 @@ + + + diff --git a/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py index b38634f..0f8a3cc 100644 --- a/build/tests/test_graph_main_coverage.py +++ b/build/tests/test_graph_main_coverage.py @@ -71,6 +71,46 @@ def native_repository(root: Path) -> Path: class AnalysisCoverageTests(unittest.TestCase): + def test_tests_project_declares_cross_directory_header_dependencies(self) -> None: + repository = BUILD_ROOT.parent + project = analysis.project_inventory( + repository, ("tests",), include_link_inputs=False + )[0] + observer = repository / "src/tests/framework/observer.cpp" + pickle = repository / "src/tests/unit/pickle.cpp" + headers = ( + repository / "src/api.h", + repository / "src/archive.h", + repository / "src/modules/extractor.h", + ) + content = json.dumps({ + "Data": { + "Source": str(observer.resolve()), + "Includes": [str(headers[0].resolve())], + } + }).encode() + + observer_files = analysis.dependency_inputs( + repository, repository / "out/cas/unused/out", + observer, content, project.headers, + ) + pickle_files = analysis.dependency_inputs( + repository, repository / "out/cas/unused/out", pickle, + json.dumps({ + "Data": { + "Source": str(pickle.resolve()), + "Includes": [str(path.resolve()) for path in headers[1:]], + } + }).encode(), + project.headers, + ) + + self.assertEqual(observer_files["src/api.h"], headers[0].read_bytes()) + self.assertEqual(pickle_files["src/archive.h"], headers[1].read_bytes()) + self.assertEqual( + pickle_files["src/modules/extractor.h"], headers[2].read_bytes() + ) + def test_compiler_manifest_accepts_only_exact_relevant_dependency_bytes(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "repo"