diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3789e01..56753d8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,27 @@ jobs: python-version: "3.13" - name: Property fuzz (50k draws, rotating seed) run: python tests/test_codegen_props.py 50000 ${{ github.run_number }} + + # Prove the lowering is real: take the generated C# and put it through the + # actual .NET compiler (the PoC sandbox has no SDK, so this is the only place + # the golden example is genuinely compiled and run, not "verified by + # construction"). + dotnet-golden: + name: golden C# compiles & runs (.NET) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Check the emitted method is still in sync with the golden host + run: python examples/golden_arraypool/verify_emit.py + - name: Compile & run the generated C# with the real compiler + run: | + dotnet new console -o "$RUNNER_TEMP/golden_app" + cp examples/golden_arraypool/Program.cs "$RUNNER_TEMP/golden_app/Program.cs" + dotnet run --project "$RUNNER_TEMP/golden_app" + diff --git a/README.md b/README.md index f886dd4a..fec9fd7d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,42 @@ python tests/run_tests.py # кейсы + cod `check` возвращает ненулевой код при наличии ошибок — годится для CI. `emit` **отказывается** генерировать C#, если в `.own` есть хоть одна ошибка. +### Что оно ловит — галерея + +В `examples/gallery/` лежат маленькие программы «как в жизни»: каждая роняет ровно +одну диагностику и снабжена C#-аналогом в комментарии. Каждый файл прибит к своему +коду тестом (`tests/test_gallery.py`), так что демо не разъезжается с тем, что +checker реально делает. Прогнать всё разом: + +```bash +python tests/test_gallery.py +``` + +| Файл | Код | Реальный C#-аналог | +|------|-----|--------------------| +| `01_leak_on_error_path` | **OWN001** | забыл `Dispose()` на early-out ветке | +| `02_use_after_release` | **OWN002** | обращение к стриму после `Dispose()` | +| `03_double_release` | **OWN003** | `Dispose()` дважды | +| `04_use_after_move` | **OWN005** | использовал значение после передачи владения | +| `05_dispose_while_view_live` | **OWN008** | `ArrayPool.Return`, пока жив `Span` над массивом | +| `06_exclusive_while_shared` | **OWN006** | пишут через `Span`, который алиасит живой `ReadOnlySpan` | +| `07_use_after_handoff` | **OWN002** | тронул буфер после того, как его забрал вызов | +| `08_stack_buffer_escapes` | **OWN015** | вернул `Span` над `stackalloc` (dangling) | +| `09_untracked_call` | **OWN040** | владение «отмыли» через непрозрачный вызов | + +`00_ok_clean` — чистый happy-path (rent → view → return), лоуэрится в exception-safe +`ArrayPool` Rent/Return. + +`check` печатает ошибку в стиле rustc — `file:line:col`, сама строка исходника и +каретка под виновным именем: + +```text +$ python -m ownlang check examples/gallery/05_dispose_while_view_live.own +examples/gallery/05_dispose_while_view_live.own:9:13: error: [OWN008] cannot release 'b' while it is borrowed + 9 | release b; // freeing the backing store while `view` is alive + ^ +``` + ### Golden-пример: настоящий ArrayPool ```bash @@ -536,9 +572,11 @@ fallback = pool`) — тоже **OWN030**: конфликтующее обеща 3. **Циклы и async отвергаются, а не анализируются** (OWN020). Нужен worklist с fixpoint и loop-инварианты владения; CFG к этому готов (DAG-проход → worklist). -4. **C# в песочнице не запускается.** Компилятора нет, поэтому golden-пример - проверен *по построению* и чекером, **не исполнен**. У себя: `dotnet run` - в `examples/golden_arraypool`. +4. **В песочнице PoC нет .NET** — golden проверен *по построению* и чекером. Но + **CI его реально компилирует и запускает** настоящим компилятором (job + `dotnet-golden`: сверяет emit-вывод с host'ом, затем `dotnet run`), так что + лоуэрение проверено исполнением — просто не в этой песочнице. У себя: `dotnet + run` в `examples/golden_arraypool`. 5. **Нет настоящей системы типов.** Ресурсы номинальные, аргументы `acquire` не типизируются, арифметики нет. Условие в `if` — непрозрачный текст: моделируется @@ -547,6 +585,12 @@ fallback = pool`) — тоже **OWN030**: конфликтующее обеща 6. **Запрещено shadowing** (OWN031). Rust разрешает; для PoC запрет проще. +7. **CI-экшены не запинены по commit-SHA** (`actions/checkout@v4` и пр. на тегах, + без `persist-credentials: false`) — SAST (zizmor) это флагует. Сознательно + отложено: SHA-пиннинг — repo-wide политика, которую ведёт Dependabot / отдельный + hardening-проход, а не один PR; джобы только checkout + прогон тестов, без push + и без секретов, так что экспозиция минимальна. + --- ## Как это ложится на твои документы diff --git a/corpus/real-world/README.md b/corpus/real-world/README.md new file mode 100644 index 00000000..1fc63176 --- /dev/null +++ b/corpus/real-world/README.md @@ -0,0 +1,45 @@ +# Real-world corpus + +Не «Damn Vulnerable .NET app», а **Damn Leaky Resource Corpus**: настоящие +паттерны багов владения ресурсами из реального .NET-кода (ArrayPool, `Dispose`, +pooled buffers), сведённые к минимальной OwnLang-модели, на которой видно, как +checker их ловит. + +Каждый кейс — папка с пятью файлами: + +| Файл | Что это | +|------|---------| +| `before.cs` | багованный C#-фрагмент (паттерн как в реальном коде) | +| `after.cs` | исправленная версия | +| `case.own` | минимальная OwnLang-модель того же владения | +| `expected-diagnostics.txt` | коды, которые checker обязан выдать на `case.own` | +| `notes.md` | паттерн, источник и честная рамка | + +Прогон (часть общего сьюта — `tests/test_corpus.py`): + +```bash +python tests/test_corpus.py +``` + +## Честная рамка (читать обязательно) + +`case.own` — это **ручная редукция** C#-паттерна, а **не** C#, который checker +прочитал: у OwnLang нет фронтенда по C#. Корпус доказывает, что **логика +владения ложится на настоящие баги** — будь это написано на OwnLang, checker бы +отверг. `before.cs`/`after.cs` репрезентативны для паттерна, это не дословный +дифф одного PR. + +## Что выразимо, а что нет + +Сейчас checker берёт «голый borrow»: leak / use-after-return / double-return / +escape / release-while-borrowed. **Не** выразимо (нужны новые модели — roadmap): +over-clear по границе `Span` (нет region/length-анализа), утечка только на +exception-path (анализ не моделирует исключения), concurrent `Dispose` (нет +конкурентности), async. + +## Кейсы + +| Кейс | Код | Реальный паттерн | +|------|-----|------------------| +| `arraypool-use-after-return` | OWN002 | rented-буфер вернули в пул, потом ещё читали slice | +| `arraypool-double-return` | OWN003 | один и тот же массив вернули в ArrayPool дважды ([#33767](https://github.com/dotnet/runtime/issues/33767)) | diff --git a/corpus/real-world/arraypool-double-return/after.cs b/corpus/real-world/arraypool-double-return/after.cs new file mode 100644 index 00000000..f22d7162 --- /dev/null +++ b/corpus/real-world/arraypool-double-return/after.cs @@ -0,0 +1,15 @@ +// AFTER (fixed): return exactly once, in finally. +using System.Buffers; + +static void Use(int n) +{ + int[] rented = ArrayPool.Shared.Rent(n); + try + { + Work(rented); + } + finally + { + ArrayPool.Shared.Return(rented); + } +} diff --git a/corpus/real-world/arraypool-double-return/before.cs b/corpus/real-world/arraypool-double-return/before.cs new file mode 100644 index 00000000..7b921f50 --- /dev/null +++ b/corpus/real-world/arraypool-double-return/before.cs @@ -0,0 +1,19 @@ +// BEFORE (buggy). Reduction of dotnet/runtime#33767 "Do not double-return +// arrays to ArrayPool": the same rented array is returned twice (here a Return +// on the success path AND a Return in finally). A double-return corrupts the +// pool — the array can later be rented out to two callers at once. +using System.Buffers; + +static void Use(int n) +{ + int[] rented = ArrayPool.Shared.Rent(n); + try + { + Work(rented); + ArrayPool.Shared.Return(rented); // returned here ... + } + finally + { + ArrayPool.Shared.Return(rented); // <-- ... and again here (double) + } +} diff --git a/corpus/real-world/arraypool-double-return/case.own b/corpus/real-world/arraypool-double-return/case.own new file mode 100644 index 00000000..27e6674e --- /dev/null +++ b/corpus/real-world/arraypool-double-return/case.own @@ -0,0 +1,14 @@ +// OwnLang model: returning the same buffer twice is a double `release`. +module Corpus +resource Buffer { + acquire rent + release give + emit_type "byte[]" + emit_acquire "ArrayPool.Shared.Rent({args})" + emit_release "ArrayPool.Shared.Return({0})" +} +fn run(n: int) { + let rented = acquire Buffer(n); // ArrayPool.Rent + release rented; // ArrayPool.Return + release rented; // returned again -> OWN003 +} diff --git a/corpus/real-world/arraypool-double-return/expected-diagnostics.txt b/corpus/real-world/arraypool-double-return/expected-diagnostics.txt new file mode 100644 index 00000000..574370e9 --- /dev/null +++ b/corpus/real-world/arraypool-double-return/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN003 diff --git a/corpus/real-world/arraypool-double-return/notes.md b/corpus/real-world/arraypool-double-return/notes.md new file mode 100644 index 00000000..7f3d8d5a --- /dev/null +++ b/corpus/real-world/arraypool-double-return/notes.md @@ -0,0 +1,15 @@ +# ArrayPool double-return + +**Pattern:** the same rented array is returned to `ArrayPool.Shared` twice — +classically a `Return` on a normal path plus another in a `finally`/`Dispose`, +or two `Dispose()` calls. The pool can then hand the same array to two renters +simultaneously. See dotnet/runtime#33767 ("Do not double-return arrays to +ArrayPool"). + +**What the checker says:** the OwnLang model trips **OWN003** (double release). + +**Honesty / scope.** As with the other cases, `case.own` is a faithful hand +reduction of the pattern (not C# ingested by the checker); `before.cs` / +`after.cs` are representative of the bug and its fix, not a verbatim PR diff. + +Reference: https://github.com/dotnet/runtime/issues/33767 diff --git a/corpus/real-world/arraypool-use-after-return/after.cs b/corpus/real-world/arraypool-use-after-return/after.cs new file mode 100644 index 00000000..4d0613e4 --- /dev/null +++ b/corpus/real-world/arraypool-use-after-return/after.cs @@ -0,0 +1,11 @@ +// AFTER (fixed): consume the buffer BEFORE returning it to the pool. +using System.Buffers; + +static int[] Divide(int dividend, int divisor) +{ + int[] quotient = ArrayPool.Shared.Rent(Size(dividend)); + Compute(quotient, dividend, divisor); + int[] result = BuildResult(quotient); // consume first ... + ArrayPool.Shared.Return(quotient); // ... then return + return result; +} diff --git a/corpus/real-world/arraypool-use-after-return/before.cs b/corpus/real-world/arraypool-use-after-return/before.cs new file mode 100644 index 00000000..9c59372d --- /dev/null +++ b/corpus/real-world/arraypool-use-after-return/before.cs @@ -0,0 +1,14 @@ +// BEFORE (buggy). Reduction of the classic ArrayPool use-after-return bug seen +// across dotnet/runtime's buffer-pooling code (e.g. the BigInteger division +// path): a rented buffer is returned to the pool, then a slice of it is still +// read while building the result. Representative of the pattern, not verbatim +// from one PR. +using System.Buffers; + +static int[] Divide(int dividend, int divisor) +{ + int[] quotient = ArrayPool.Shared.Rent(Size(dividend)); + Compute(quotient, dividend, divisor); + ArrayPool.Shared.Return(quotient); // <-- returned to the pool here ... + return BuildResult(quotient); // <-- ... but still read here (UAF) +} diff --git a/corpus/real-world/arraypool-use-after-return/case.own b/corpus/real-world/arraypool-use-after-return/case.own new file mode 100644 index 00000000..882d972a --- /dev/null +++ b/corpus/real-world/arraypool-use-after-return/case.own @@ -0,0 +1,18 @@ +// OwnLang model. `acquire` == ArrayPool.Rent, `release` == ArrayPool.Return, +// a `borrow`/call == reading a Span over the buffer. Reading after `release` +// is the use-after-return the BEFORE code commits. +module Corpus +resource Buffer { + acquire rent + release give + emit_type "byte[]" + emit_acquire "ArrayPool.Shared.Rent({args})" + emit_release "ArrayPool.Shared.Return({0})" + emit_borrow "{0}.AsSpan()" +} +extern fn BuildResult(borrow Buffer); +fn divide(n: int) { + let quotient = acquire Buffer(n); // ArrayPool.Rent + release quotient; // ArrayPool.Return <-- too early + BuildResult(quotient); // read after return -> OWN002 +} diff --git a/corpus/real-world/arraypool-use-after-return/expected-diagnostics.txt b/corpus/real-world/arraypool-use-after-return/expected-diagnostics.txt new file mode 100644 index 00000000..3a36fa92 --- /dev/null +++ b/corpus/real-world/arraypool-use-after-return/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN002 diff --git a/corpus/real-world/arraypool-use-after-return/notes.md b/corpus/real-world/arraypool-use-after-return/notes.md new file mode 100644 index 00000000..2a0e3dcb --- /dev/null +++ b/corpus/real-world/arraypool-use-after-return/notes.md @@ -0,0 +1,27 @@ +# ArrayPool use-after-return + +**Pattern:** a buffer rented from `ArrayPool.Shared` is `Return`ed to the +pool, and then a slice of it is still read. Once returned, the array may be +handed to another renter, so the later read sees torn/foreign data. This is one +of the most common real ArrayPool bugs; it shows up repeatedly in +dotnet/runtime's buffer-pooling code (the BigInteger division/GCD path is the +oft-cited example). + +**What the checker says:** the OwnLang model trips **OWN002** (use after +release) — `release` is `ArrayPool.Return`, and `BuildResult(quotient)` reads +the buffer afterwards. + +```text +$ python -m ownlang check corpus/real-world/arraypool-use-after-return/case.own +case.own:14:14: error: [OWN002] borrow 'quotient' after it was released + 14 | BuildResult(quotient); // read after return -> OWN002 + ^ +``` + +**Honesty / scope.** `case.own` is a *hand reduction* of the C# pattern, not C# +the checker ingested — OwnLang has no C# front-end. It demonstrates that the +ownership *logic* maps onto the real bug: had the code been written in OwnLang, +the checker would have rejected it. The real-world specifics (the division math, +the exact slice bounds) are abstracted to `acquire`/`release`/`borrow`. +`before.cs` / `after.cs` capture the pattern; they are representative, not a +verbatim copy of a single PR. diff --git a/examples/gallery/00_ok_clean.own b/examples/gallery/00_ok_clean.own new file mode 100644 index 00000000..2b74767a --- /dev/null +++ b/examples/gallery/00_ok_clean.own @@ -0,0 +1,19 @@ +// Clean: acquire, look at it mutably then read-only, hand it back. +// Lowers to ArrayPool Rent/Return inside a try/finally (exception-safe). +module Gallery +resource Buffer { + acquire rent + release give + emit_type "byte[]" + emit_acquire "ArrayPool.Shared.Rent({args})" + emit_release "ArrayPool.Shared.Return({0})" + emit_borrow "{0}.AsSpan()" +} +extern fn Fill(borrow_mut Buffer); +extern fn Hash(borrow Buffer); +fn process(size: int) { + let buf = acquire Buffer(size); + borrow_mut buf as bytes { Fill(bytes); } + borrow buf as view { Hash(view); } + release buf; +} diff --git a/examples/gallery/01_leak_on_error_path.own b/examples/gallery/01_leak_on_error_path.own new file mode 100644 index 00000000..a8dc13a9 --- /dev/null +++ b/examples/gallery/01_leak_on_error_path.own @@ -0,0 +1,10 @@ +// OWN001 — leak on one path. +// Real C#: you Dispose() in the happy branch but forget it on the early-out. +module Gallery +resource Conn { acquire open release close } +fn handle(flag: int) { + let c = acquire Conn(flag); + if (flag) { + release c; // released here ... + } // ... but on the else path c is never closed -> leak +} diff --git a/examples/gallery/02_use_after_release.own b/examples/gallery/02_use_after_release.own new file mode 100644 index 00000000..7345f8dd --- /dev/null +++ b/examples/gallery/02_use_after_release.own @@ -0,0 +1,10 @@ +// OWN002 — use after release (definite, on every path). +// Real C#: touching a stream after Dispose() -> ObjectDisposedException. +module Gallery +resource Conn { acquire open release close } +extern fn Send(borrow Conn); +fn run() { + let c = acquire Conn(1); + release c; + Send(c); // c is already closed +} diff --git a/examples/gallery/03_double_release.own b/examples/gallery/03_double_release.own new file mode 100644 index 00000000..465a795a --- /dev/null +++ b/examples/gallery/03_double_release.own @@ -0,0 +1,9 @@ +// OWN003 — double release. +// Real C#: Dispose() called twice (and the type isn't idempotent). +module Gallery +resource Conn { acquire open release close } +fn run() { + let c = acquire Conn(1); + release c; + release c; +} diff --git a/examples/gallery/04_use_after_move.own b/examples/gallery/04_use_after_move.own new file mode 100644 index 00000000..6fc13638 --- /dev/null +++ b/examples/gallery/04_use_after_move.own @@ -0,0 +1,11 @@ +// OWN005 — use after ownership was moved away. +// Real C#: using a buffer after you handed the only owner to someone else. +module Gallery +resource Buffer { acquire rent release give } +extern fn Hash(borrow Buffer); +fn run() { + let a = acquire Buffer(64); + let b = move a; // ownership transferred to b + Hash(a); // a is now empty + release b; +} diff --git a/examples/gallery/05_dispose_while_view_live.own b/examples/gallery/05_dispose_while_view_live.own new file mode 100644 index 00000000..08ff0d63 --- /dev/null +++ b/examples/gallery/05_dispose_while_view_live.own @@ -0,0 +1,12 @@ +// OWN008 — release while a borrow (a view) is still outstanding. +// Real C#: returning the array to ArrayPool while a Span over it is in use. +module Gallery +resource Buffer { acquire rent release give } +extern fn Fill(borrow_mut Buffer); +fn run() { + let b = acquire Buffer(64); + borrow_mut b as view { + release b; // freeing the backing store while `view` is alive + Fill(view); + } +} diff --git a/examples/gallery/06_exclusive_while_shared.own b/examples/gallery/06_exclusive_while_shared.own new file mode 100644 index 00000000..f65e6616 --- /dev/null +++ b/examples/gallery/06_exclusive_while_shared.own @@ -0,0 +1,12 @@ +// OWN006 — exclusive (mutable) access while a shared view is live. +// Real C#: writing through one Span while another ReadOnlySpan aliases it. +module Gallery +resource Buffer { acquire rent release give } +extern fn Fill(borrow_mut Buffer); +fn run() { + let b = acquire Buffer(64); + borrow b as readers { + Fill(b); // needs exclusive access, but `readers` is live + } + release b; +} diff --git a/examples/gallery/07_use_after_handoff.own b/examples/gallery/07_use_after_handoff.own new file mode 100644 index 00000000..c5159350 --- /dev/null +++ b/examples/gallery/07_use_after_handoff.own @@ -0,0 +1,11 @@ +// OWN002 — use after ownership was consumed by a callee. +// Real C#: a method takes ownership (it will Dispose), then you touch it again. +module Gallery +resource Buffer { acquire rent release give } +extern fn Store(consume Buffer); +extern fn Hash(borrow Buffer); +fn run() { + let b = acquire Buffer(64); + Store(b); // Store now owns b (and will free it) + Hash(b); // ... but we used it afterwards +} diff --git a/examples/gallery/08_stack_buffer_escapes.own b/examples/gallery/08_stack_buffer_escapes.own new file mode 100644 index 00000000..3d2a3603 --- /dev/null +++ b/examples/gallery/08_stack_buffer_escapes.own @@ -0,0 +1,8 @@ +// OWN015 — a stack-backed buffer cannot escape its function. +// Real C#: returning a Span over a stackalloc -> it dangles the instant +// the frame pops. +module Gallery +fn make() -> Buffer { + let tmp = Buffer.stack(64); // lives on the stack frame of make() + return tmp; // ... must not leave it +} diff --git a/examples/gallery/09_untracked_call.own b/examples/gallery/09_untracked_call.own new file mode 100644 index 00000000..ec4e6f93 --- /dev/null +++ b/examples/gallery/09_untracked_call.own @@ -0,0 +1,9 @@ +// OWN040 — an unknown call is a hole the size of a bus, so it is forbidden. +// Real C#: ownership "laundered" through an opaque helper the checker can't see. +module Gallery +resource Buffer { acquire rent release give } +fn run() { + let b = acquire Buffer(64); + Mystery(b); // not declared as extern fn / local fn -> rejected + release b; +} diff --git a/examples/golden_arraypool/verify_emit.py b/examples/golden_arraypool/verify_emit.py new file mode 100644 index 00000000..2264bbc3 --- /dev/null +++ b/examples/golden_arraypool/verify_emit.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +CI guard for the runnable golden. + +`Program.cs` pastes the `process` method verbatim from `ownlang emit +buffer.own` and wraps it in host code (Main + Fill/Hash stubs). This checks the +pasted method is still **byte-for-byte** what the generator produces, so the +runnable example can't drift from the checker's output before the .NET job +compiles it. +""" + +from __future__ import annotations + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(_HERE, "..", "..")) + +from ownlang.parser import parse # noqa: E402 +from ownlang.codegen import generate # noqa: E402 + + +def _method_lines(text: str) -> list[str]: + """The `process(...)` method, preserving each line's exact text.""" + lines = text.splitlines() + start = next(i for i, line in enumerate(lines) + if "static void process(" in line) + out: list[str] = [] + depth = 0 + seen_brace = False + for line in lines[start:]: + out.append(line) + depth += line.count("{") - line.count("}") + if "{" in line: + seen_brace = True + if seen_brace and depth == 0: + break + return out + + +def main() -> int: + """Fail (1) if Program.cs's process() no longer matches `ownlang emit`.""" + with open(os.path.join(_HERE, "buffer.own"), encoding="utf-8") as f: + emitted = generate(parse(f.read())) + with open(os.path.join(_HERE, "Program.cs"), encoding="utf-8") as f: + program = f.read() + want = _method_lines(emitted) + have = program.splitlines() + # `want` must appear as a contiguous, byte-for-byte run inside Program.cs. + n = len(want) + ok = any(have[i:i + n] == want for i in range(len(have) - n + 1)) + if not ok: + print("Program.cs is out of sync with `ownlang emit buffer.own`.") + print("--- emitted process method ---") + print("\n".join(want)) + return 1 + print(f"golden in sync: process() ({n} lines) matches `ownlang emit`") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 634aa8e6..9f4febb0 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -47,7 +47,7 @@ def cmd_check(path: str) -> int: diags, _ = _collect(src) errors = [d for d in diags if d.severity == Severity.ERROR] for d in diags: - print(d.render(path)) + print(d.render_pretty(path, src)) if not diags: print(f"{path}: ok — no ownership problems found") n = len(errors) @@ -61,7 +61,7 @@ def cmd_emit(path: str) -> int: errors = [d for d in diags if d.severity == Severity.ERROR] if errors or mod is None: for d in diags: - print(d.render(path), file=sys.stderr) + print(d.render_pretty(path, src), file=sys.stderr) print(f"\nrefusing to generate C#: {len(errors)} error(s).", file=sys.stderr) return 1 print(generate(mod)) # type: ignore[arg-type] diff --git a/ownlang/diagnostics.py b/ownlang/diagnostics.py index 1a17406e..1e51d607 100644 --- a/ownlang/diagnostics.py +++ b/ownlang/diagnostics.py @@ -16,9 +16,14 @@ from __future__ import annotations +import re from dataclasses import dataclass from enum import Enum +# the first single-quoted identifier in a message is the thing it is about +# ('b' in "use 'b' after it was released"); used to place a caret under it. +_SUBJECT_RE = re.compile(r"'([^']+)'") + class Severity(Enum): ERROR = "error" @@ -78,8 +83,42 @@ class Diagnostic: def title(self) -> str: return TITLES.get(self.code, "") + def _caret_col(self, src_line: str) -> int | None: + """1-based column of this diagnostic in `src_line`: the position of the + identifier it names. None if it cannot be located.""" + m = _SUBJECT_RE.search(self.message) + if m: + name = m.group(1) + # prefer a whole-word match so 'a' lands on the argument in `Hash(a)`, + # not on the 'a' inside `Hash`; fall back to a plain substring search. + wb = re.search(rf"\b{re.escape(name)}\b", src_line) + if wb: + return wb.start() + 1 + idx = src_line.find(name) + if idx >= 0: + return idx + 1 + stripped = len(src_line) - len(src_line.lstrip()) + return stripped + 1 if src_line.strip() else None + def render(self, filename: str = "") -> str: + """Plain one-line rendering: `file:line: severity: [code] message`.""" return ( f"{filename}:{self.line}: {self.severity.value}: " f"[{self.code}] {self.message}" ) + + def render_pretty(self, filename: str, source: str) -> str: + """A rustc-style rendering: a `file:line:col` header, the offending + source line, and a caret under the named identifier. Falls back to the + plain header when the line/column cannot be resolved.""" + lines = source.splitlines() + src_line = lines[self.line - 1] if 1 <= self.line <= len(lines) else "" + col = self._caret_col(src_line) + loc = f"{filename}:{self.line}" + (f":{col}" if col else "") + out = [f"{loc}: {self.severity.value}: [{self.code}] {self.message}"] + if src_line.strip(): + gutter = f" {self.line} | " + out.append(f"{gutter}{src_line}") + if col: + out.append(" " * (len(gutter) + col - 1) + "^") + return "\n".join(out) diff --git a/tests/run_tests.py b/tests/run_tests.py index 59e6ed01..ac1409bb 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1027,9 +1027,20 @@ def run() -> int: import test_codegen_props # noqa: E402 pf_rc = test_codegen_props.run(iterations=3000, seed=1234) + # The "what it catches" gallery: every examples/gallery/ file must still + # produce exactly the diagnostic it advertises, so the demo can't drift. + import test_gallery # noqa: E402 + gl_rc = test_gallery.run() + + # Real-world corpus: each case.own (a reduction of a real ArrayPool/Dispose + # bug) must still produce the diagnostics it documents. + import test_corpus # noqa: E402 + co_rc = test_corpus.run() + return 1 if (failed or cg_fail or golden_fails or buffer_fails or escape_fails or branchy_fails or nest_fails - or order_fails or helper_fails or cc_rc or pf_rc) else 0 + or order_fails or helper_fails or cc_rc or pf_rc + or gl_rc or co_rc) else 0 if __name__ == "__main__": diff --git a/tests/test_corpus.py b/tests/test_corpus.py new file mode 100644 index 00000000..4f62c0a9 --- /dev/null +++ b/tests/test_corpus.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Real-world corpus, as a self-checking test. + +Each corpus/real-world// folder holds a real ArrayPool/ownership bug +pattern: before.cs (buggy) / after.cs (fixed) / case.own (a faithful OwnLang +reduction of it) / expected-diagnostics.txt (the codes the checker must produce +on case.own) / notes.md (the pattern, the source, and the honesty caveat). + +This module runs every case.own and asserts its diagnostics match the +expected-diagnostics.txt next to it, so the corpus stays honest: if the checker +ever stops catching one of these real patterns, the suite goes red. + +NOTE: case.own is a hand reduction of the C# pattern, not C# the checker +ingested -- OwnLang has no C# front-end. The corpus shows the ownership *logic* +maps onto real bugs, not that the tool scanned real C#. + +Run: python tests/test_corpus.py + python tests/run_tests.py (runs it as part of the suite) +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.parser import parse, ParseError # noqa: E402 +from ownlang.lexer import LexError # noqa: E402 +from ownlang.cfg import build_cfg, collect_signatures, collect_policies # noqa: E402 +from ownlang.analysis import analyze # noqa: E402 +from ownlang.buffers import validate_policies # noqa: E402 +from ownlang.diagnostics import Severity # noqa: E402 + +_CORPUS = os.path.join(os.path.dirname(__file__), "..", "corpus", "real-world") + + +def _codes(src: str) -> list[str]: + """The error codes the checker produces for one `.own` source string.""" + try: + mod = parse(src) + except (ParseError, LexError): + return ["OWN020"] + rnames = {r.name for r in mod.resources} + sigs = collect_signatures(mod) + out = [d.code for d in validate_policies(collect_policies(mod)) + if d.severity == Severity.ERROR] + for fn in mod.functions: + cfg, d1 = build_cfg(fn, rnames, sigs) + out += [d.code for d in (d1 + analyze(cfg)) if d.severity == Severity.ERROR] + return out + + +def _cases() -> list[str]: + """The case directory names under corpus/real-world/.""" + if not os.path.isdir(_CORPUS): + return [] + return sorted(d for d in os.listdir(_CORPUS) + if os.path.isdir(os.path.join(_CORPUS, d))) + + +def run() -> int: + """Check every corpus case against its expected diagnostics; return 0/1.""" + fails: list[str] = [] + rows: list[tuple[str, str]] = [] + checked = 0 + matched = 0 + for case in _cases(): + d = os.path.join(_CORPUS, case) + own = os.path.join(d, "case.own") + exp = os.path.join(d, "expected-diagnostics.txt") + for required in (own, exp, os.path.join(d, "before.cs"), + os.path.join(d, "after.cs"), os.path.join(d, "notes.md")): + if not os.path.exists(required): + fails.append(f"{case}: missing {os.path.basename(required)}") + if not (os.path.exists(own) and os.path.exists(exp)): + continue + checked += 1 + with open(exp, encoding="utf-8") as f: + want = sorted(w for w in f.read().split() if w) + with open(own, encoding="utf-8") as f: + got = sorted(set(_codes(f.read()))) + if got != want: + fails.append(f"{case}: expected {want}, got {got}") + else: + matched += 1 + rows.append((case, ",".join(want))) + + print("real-world corpus (corpus/real-world/):") + width = max((len(c) for c, _ in rows), default=0) + for case, codes in rows: + print(f" {case:<{width}} {codes}") + for f in fails: + print(f"CORPUS FAIL: {f}") + print(f"corpus: {matched}/{checked} cases match their expected diagnostics") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_gallery.py b/tests/test_gallery.py new file mode 100644 index 00000000..0076ce50 --- /dev/null +++ b/tests/test_gallery.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +The "what it catches" gallery, as an executable, self-checking demo. + +Each file in examples/gallery/ is a tiny, real-shaped program that trips exactly +one ownership/borrow diagnostic, with a comment giving the real C# analog. This +module pins every example to the code it is supposed to produce, so the demo can +never quietly drift from what the checker actually does. + +Run: python tests/test_gallery.py (prints the gallery table) + python tests/run_tests.py (runs it as part of the suite) +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.parser import parse, ParseError # noqa: E402 +from ownlang.lexer import LexError # noqa: E402 +from ownlang.cfg import build_cfg, collect_signatures, collect_policies # noqa: E402 +from ownlang.analysis import analyze # noqa: E402 +from ownlang.buffers import validate_policies # noqa: E402 +from ownlang.diagnostics import Severity, TITLES # noqa: E402 + +_GALLERY = os.path.join(os.path.dirname(__file__), "..", "examples", "gallery") + +# file -> the diagnostic it is meant to demonstrate (None == must check clean), +# plus a one-line real-world analog for the printed table. +MANIFEST = [ + ("00_ok_clean.own", None, "clean: rent -> view -> return, exception-safe"), + ("01_leak_on_error_path.own", "OWN001", "forgot Dispose() on the early-out path"), + ("02_use_after_release.own", "OWN002", "touched a stream after Dispose()"), + ("03_double_release.own", "OWN003", "Dispose() called twice"), + ("04_use_after_move.own", "OWN005", "used a value after moving ownership away"), + ("05_dispose_while_view_live.own","OWN008","ArrayPool.Return while a Span over it is live"), + ("06_exclusive_while_shared.own","OWN006", "wrote through a Span aliased by a ReadOnlySpan"), + ("07_use_after_handoff.own", "OWN002", "used a buffer after a callee took ownership"), + ("08_stack_buffer_escapes.own", "OWN015", "returned a Span over a stackalloc (dangling)"), + ("09_untracked_call.own", "OWN040", "ownership laundered through an opaque call"), +] + + +def _codes(src: str) -> list[str]: + """The error codes the checker produces for one `.own` source string.""" + try: + mod = parse(src) + except (ParseError, LexError): + return ["OWN020"] + rnames = {r.name for r in mod.resources} + sigs = collect_signatures(mod) + out = [d.code for d in validate_policies(collect_policies(mod)) + if d.severity == Severity.ERROR] + for fn in mod.functions: + cfg, d1 = build_cfg(fn, rnames, sigs) + d2 = analyze(cfg) + out += [d.code for d in (d1 + d2) if d.severity == Severity.ERROR] + return out + + +def _render_smoke() -> list[str]: + """The pretty CLI rendering must carry a line:col header, the source line, + and a caret on the named identifier.""" + fails: list[str] = [] + with open(os.path.join(_GALLERY, "02_use_after_release.own"), + encoding="utf-8") as f: + src = f.read() + mod = parse(src) + rnames = {r.name for r in mod.resources} + sigs = collect_signatures(mod) + diags = [] + for fn in mod.functions: + cfg, d1 = build_cfg(fn, rnames, sigs) + diags += d1 + analyze(cfg) + pretty = "\n".join(d.render_pretty("f.own", src) for d in diags) + if ":9:" not in pretty: + fails.append("render_pretty: missing line:col header") + if "\n" not in pretty or "^" not in pretty: + fails.append("render_pretty: missing source line / caret") + return fails + + +def run() -> int: + """Check every gallery example against its documented code; return 0/1.""" + fails: list[str] = _render_smoke() + rows: list[tuple[str, str, str]] = [] + matched = 0 + for name, want, analog in MANIFEST: + path = os.path.join(_GALLERY, name) + try: + with open(path, encoding="utf-8") as f: + got = sorted(set(_codes(f.read()))) + except FileNotFoundError: + fails.append(f"{name}: missing") + continue + if want is None: + ok = not got + if not ok: + fails.append(f"{name}: expected clean, got {got}") + shown = "clean" + else: + ok = got == [want] + if not ok: + fails.append(f"{name}: expected [{want}], got {got}") + shown = want + if ok: + matched += 1 + rows.append((name, shown, analog)) + + width = max((len(n) for n, _, _ in rows), default=0) + print("what it catches — gallery (examples/gallery/):") + for name, code, analog in rows: + title = TITLES.get(code, "") + print(f" {name:<{width}} {code:<7} {analog}") + if code != "clean": + print(f" {'':<{width}} {'':<7} ({title})") + for f in fails: + print(f"GALLERY FAIL: {f}") + print(f"gallery: {matched}/{len(MANIFEST)} " + f"examples match their documented diagnostic") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run())