Skip to content

Report source file:line (or symbol+offset) in diagnostics (issue #74) - #82

Merged
sidick merged 2 commits into
mainfrom
debug-line-info
Sep 16, 2026
Merged

sidick merged 2 commits into
mainfrom
debug-line-info

Conversation

@sidick

@sidick sidick commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Closes #74. Sanitizer violations now say where in the source they happened:

invalid 1-byte write at 0x000032a0 (heap redzone) from PC 0x00002b38 (at work:memtest.s:281)

Line 281 of that source is exactly move.b #$ab,32(a2) ; the overrun write itself — verified, since a wrong line is worse than none.

Two sources, best first: a HUNK_DEBUG LINE block's (line, offset) pairs, else HUNK_SYMBOL as symbol+offset, else the bare address. The raw PC is always kept alongside rather than replaced — it's what a disassembly needs, and sparse line data points at a statement rather than a specific instruction.

The headline result: it answers the question that started all this

A real gcc-built C program with a classic strcpy stack overflow:

return address corrupted at stack slot 0x00ffffe0:
  expected 0x00002b40, found 0x41414141 from PC 0x00002b10 (at ___main+0x3c)

found 0x41414141 is the ASCII AAAA that overflowed — exactly the diagnostic value protected by making the report rollup conditional in #79 — and (at ___main+0x3c) is the symbol fallback working on a binary with no line data at all.

What investigating real toolchains changed

Scope shifted twice, and both findings are in the docs rather than glossed over:

  • SAS/C's LINE block is byte-compatible with PhxAss's and LawBreaker's, so one parser serves three producers. (SAS/C also emits OPTS and SRC6 alongside; both ignored.)
  • m68k-amigaos-gcc emits untagged stabs. Its payload starts 00 00 00 4c 00 00 00 10 ff ff ff ff — where LINE/OPTS/SRC6 carry ASCII, it has none. So -g alone gives no file:line here, which matters because gcc users are the audience that motivated the whole sanitizer effort. They get symbol+offset instead, as above. Stabs is follow-up work: an untagged block has no magic to dispatch on, so identifying it safely is its own problem.

The magic dispatch is an explicit enum with a documented skip arm, so adding stabs is a match arm rather than unpicking an if. A test uses gcc's real untagged bytes to prove it's skipped cleanly rather than mistaken for LINE.

Notes for review

  • Lazy parsing. parse/load capture raw payloads cheaply and never interpret them; decoding happens on first query. No signature changes, no existing caller touched, and the common case doesn't pay for debug info that can dominate a file (LawBreaker is 776 bytes, 456 of it debug).
  • report_with takes a closure, not the debug info. A shadow map knows PCs and nothing about hunks or load addresses, so the lookup stays on the CLI side and sanitize.rs gains no loader dependency. report() still delegates with a no-op resolver.
  • New fixtures/linetest carries real LINE data (PhxAss LINEDEBUG). It replaces tests pointing at a scratch file and at LawBreaker (unvendorable under Enforcer's licence) — both would have silently skipped forever once those files vanished. Deliberately PhxAss-only with no generator, since amiga_asm.py can't emit debug hunks and a generator producing a debug-free binary would look authoritative while lacking the point.
  • That fixture proved a real requirement: PhxAss's data-hunk pairs are not offset-monotonic (line 33→0x0e, 36→0x00, 37→0x15), so the parser must sort rather than trust file order. Trusting it would have produced wrong line numbers, not missing ones.

Documented limits

Line data is sparse, so a PC between entries is attributed to the earlier statement. And static functions never appear in HUNK_SYMBOL, so a violation inside one falls to the nearest exported symbol — in the gcc example above the smash is actually in victim, reported as ___main+0x3c. Both are in the CLI reference as warnings rather than left to surprise someone.

999 tests green, clippy clean, mkdocs build --strict clean.

Separate finding, worth its own issue

gcc's malloc doesn't reach AllocMem — libnix takes one chunk and sub-allocates via exec.library/Allocate from its own MemHeader (11 Allocate calls for one malloc). So --sanitize's heap redzones don't cover malloc in C programs; a buf[32] overflow on a malloc(32) goes undetected. Stack detection is unaffected (hence the result above). I'll file that separately — it's a real limit on what the heap detectors reach for the gcc audience.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AKBJRT9j5APTyKyZtj8f23

sidick and others added 2 commits September 16, 2026 17:26
Sanitizer violations now say where in the source they happened:

  invalid 1-byte write at 0x000032a0 (heap redzone)
    from PC 0x00002b38 (at work:memtest.s:281)

and line 281 of that source is exactly the offending instruction.

Two sources, best first: a HUNK_DEBUG `LINE` block's (line, offset)
pairs, else the binary's HUNK_SYMBOL table as symbol+offset. The raw PC
is always kept alongside rather than replaced -- it is what a
disassembly needs, and sparse line data points at a statement rather
than a specific instruction.

Parsing is lazy: `parse`/`load` capture the raw HUNK_DEBUG and
HUNK_SYMBOL payloads cheaply and never interpret them, with structured
decoding happening on first query. That meant no signature changes and
no existing caller touched, and it keeps the common case from paying for
debug info that can dominate a file (LawBreaker is 776 bytes of which
456 is debug).

`ShadowMap::report_with` takes a resolver *closure* rather than reaching
for the information: a shadow map knows PCs and nothing about hunks,
load addresses or debug blocks, so the lookup stays on the CLI's side
and `crate::sanitize` gains no dependency on the loader. `report()`
still exists, delegating with a no-op resolver.

Investigating what real toolchains actually emit reshaped the scope
twice, and both findings are documented rather than glossed:

- SAS/C's `LINE` block (DEBUG=LINE) is byte-compatible with the one
  PhxAss emits (LINEDEBUG) and with LawBreaker's, so one parser serves
  three producers. SAS/C also emits OPTS and SRC6 blocks alongside.
- m68k-amigaos-gcc emits *untagged stabs* -- its debug payload begins
  `00 00 00 4c 00 00 00 10 ff ff ff ff`, so where LINE/OPTS/SRC6 carry
  ASCII it has none. gcc therefore gets no file:line from this, which
  matters because gcc users are the audience that motivated the whole
  sanitizer effort. They do get symbol+offset: a real gcc-built stack
  smash now reports `found 0x41414141 ... (at ___main+0x3c)`. Stabs is
  filed as follow-up work; an untagged block has no magic to dispatch
  on, so identifying it safely is its own problem, and a wrong line
  number is worse than none.

The magic dispatch is an explicit enum with a documented
unrecognised-and-skipped arm, so adding stabs is a match arm rather
than unpicking an `if`. A test uses gcc's real untagged byte sequence
to prove it is skipped cleanly rather than mistaken for LINE.

New fixtures/linetest carries real LINE data, built by PhxAss with
LINEDEBUG. It replaces tests pointing at a scratch file and at
LawBreaker (unvendorable under Enforcer's licence), both of which would
have silently skipped forever once those files vanished. It is
deliberately PhxAss-only with no generator, since amiga_asm.py cannot
emit debug hunks and a generator producing a debug-free binary would
look authoritative while lacking the entire point.

It also proved a real requirement: PhxAss's data-hunk LINE pairs are
*not* offset-monotonic (line 33 -> 0x0e, 36 -> 0x00, 37 -> 0x15), so
the parser must sort rather than trust file order. Assuming order there
would have produced wrong line numbers, not missing ones.

Documented limits: line data is sparse, so a PC between entries is
attributed to the earlier statement; and `static` functions never
appear in HUNK_SYMBOL, so a violation inside one falls to the nearest
exported symbol, which can be a surprising name with a large offset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKBJRT9j5APTyKyZtj8f23
cargo fmt --all --check was failing on an assert! that rustfmt wants
split across lines. Tests and clippy were green; this was purely
formatting, added after the gate run that formatted everything else.

The pre-push check missed it because it read $? through a pipe, which
reports the exit status of the last command in the pipeline (head), not
cargo's -- so a genuine exit 1 read as a pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKBJRT9j5APTyKyZtj8f23
@sidick
sidick merged commit 0d37bc5 into main Sep 16, 2026
9 checks passed
@sidick
sidick deleted the debug-line-info branch September 16, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use HUNK_DEBUG LINE information to report source file:line instead of raw addresses

1 participant