Skip to content

fix: repair eight correctness defects found by a whole-tree audit - #227

Open
r0ny123 wants to merge 25 commits into
danielplohmann:masterfrom
r0ny123:fix/audit-2026-08-07-correctness
Open

fix: repair eight correctness defects found by a whole-tree audit#227
r0ny123 wants to merge 25 commits into
danielplohmann:masterfrom
r0ny123:fix/audit-2026-08-07-correctness

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Eight fixes from a whole-tree correctness audit. Each one is either an analysis abort (a single malformed input turns the whole run into an error report) or a silent recall/CFG loss (analysis reports ok while producing wrong or missing output). Every commit carries a regression test that was verified to fail on the code before it.

Three of the eight started as a single reported site and grew once the same root cause was swept across the tree; those commits fix the sibling occurrences in the same change and record the sites deliberately left alone.

What each commit fixes

Commit Class Effect before the fix
fix(loaders) PE header copy wrong mapping raw_offset > 0x200 skipped the single most common PointerToRawData, so the sentinel survived and the whole raw file was copied to RVA 0
fix(loaders) zero VirtualSize total recall loss a zero-width code area left code_areas non-empty, flipping isInCodeAreas into a filter that rejects every address — ok with zero functions
fix(core) jump_targets purge CFG loss removing a fall-through ref deleted an unrelated branch target, so its block stopped being a block start
fix(cil) block terminators double-counted instructions blocks ran through throw/rethrow/endfinally/endfilter into a separately seeded handler, inflating counts and double-hashing instructions
fix(cil) malformed rows / #US analysis abort an unreachable except clause and an unguarded None heap each discarded every remaining method
fix(aarch64) jump-table anchor wrong targets one variable served as both table address and rebase anchor; every target was off by table - anchor
fix(core) mapped-image strings wrong data image-VA arithmetic was applied to a raw-file buffer that still validated against the mapped size
fix(labels) legacy Rust symbols analysis abort an element count taken before truncation produced int(''), whose ValueError escaped every guard

Each commit message states the mechanism, why the guard that looked like it covered the case does not, and the measurement where one exists (for example the CIL block change takes the bundled njrat fixture from 8454 block instructions to 8453, and the AArch64 targets are off by exactly 0xE8 on the committed Mach-O switch fixture).

Sibling sweeps included

  • zero VirtualSize — also fixed in BinaryInfo.getSections, the AArch64 executable-range helper (which used the gate-on-size variant), and the ELF and Mach-O getCodeAreas. DelphiPythiaProvider, PeSynthesizer and the ELF branch of the AArch64 helper were checked and left unchanged.
  • jump_targets refcounting — the shared base plus the CIL and Dalvik copies of the same code.
  • CIL terminators — the disassembler already treated all four opcodes as terminators; only the block model's list was short.

Validation

make test      # 944 passed, 1 skipped
make lint      # ruff check + ruff format --check clean

Test count moves from 939 to 944; the five new tests are the regression tests for the fixes above (the rest are additions to existing suites). No committed fixture baseline moved.

Each new or extended test was run against the pre-fix tree and confirmed to fail there, so none of them is vacuous.

Note on the diff

This branch is based on the branch of #226, which is still open against the same base. Until that one merges, this PR's file list shows both sets of changes — the eight commits above are the content of this PR, and the diff will shrink on its own once #226 lands.

r0ny123 added 25 commits August 6, 2026 14:18
end-of-file-fixer appended a newline to an obfuscated fuzz corpus entry the first
time one was committed, corrupting it rather than tidying it. The same hazard
already applied to the XOR-obfuscated malware fixtures and to
tests/fuzz_regressions/; only the ApiScout databases were excluded.

The exclude now covers all four, while still linting the READMEs inside them.
Three distinct ways ElfFileLoader.getAbi and the Mach-O readers broke against
current LIEF.

getAbi caught lief.bad_file, and that attribute no longer exists in lief 0.17 -
hasattr reports False - so the moment the handler was needed it would itself have
raised AttributeError. It also read .name off identity_os_abi, but LIEF returns a
plain int for an OS ABI it has no enum member for, and .name on an int is another
AttributeError. Now probes for the name and catches ValueError, naming no LIEF
symbol at all so it survives further drift.

Deliberately not widened to `except Exception`: AttributeError and TypeError here
are the defect classes the fuzzing oracle exists to surface.

Separately, both Mach-O readers already handle an unknown CPU type, but LIEF's
RuntimeWarning still propagated, so a consumer running under -W error saw a crash
on a malformed header instead of the unsupported metadata the code returns. That
also matters now the suite runs with filterwarnings=error.
Two defects on the SmdaReport.fromDict path, both invisible to the test suite and
both reachable by any consumer importing a report - MCRIT included.

Validation. fromDict read fields unconditionally, so a truncated report surfaced
as a KeyError from somewhere mid-rebuild rather than the ValueError the
surrounding validation uses, and nothing constrained the values that did arrive.
The fuzzer found four separate ways through, each one narrower than the last, so
this lands as a single boundary rather than a pile of point guards: required
fields must be present; containers must be containers; addresses and sizes must
be integers rather than any number, since struct.pack_into rejects a float
outright; and every address - report scalars, xcfg keys, function offsets, block
addresses - must fit the 64-bit space the synthesizers pack them into.

The xcfg key was the subtle one. It becomes the function offset via
int(function_addr) and had no sign check, so a negative key reached an ELF program
header as p_vaddr = -14200832. It also slipped past the image-span bound, because
that measures max - min and a negative floor under a positive ceiling still
measured a legal 17 MB.

The address check deliberately sits after the container-shape checks. Putting it
first made every subtest in test_function_rejects_malformed_container_fields
short-circuit on the null offset of a bare SmdaFunction, so they kept passing
while no longer testing the field each one names - a regression only the
diff-coverage gate surfaced.

Nesting depth. SmdaFunction.fromDict passed _calculateNestingDepth() as a
dict.get() default, and Python evaluates that whether or not the key is present,
so every function on the cached path built a full dominator tree and discarded it.
Now an explicit branch: verified 0 calls with the field present, 1 without.
src/smda/synthesis/ writes binary headers from report-derived integers and is
documented as not hardened against adversarial input. The new fuzz target found
three ways to reach struct.pack with a value it cannot encode.

An inverted segment span. The synthetic section covering functions outside every
known section took its floor from function offsets and its ceiling from function
extent ends - independent quantities, aligned at different granularities (down to
PAGE_SIZE, up to 16). Real binaries always have an extent end above the offset it
started from; a report whose blocks sit below their own function offset does not,
and struct.pack got -4080 for an unsigned field. The sweep found the same shape at
four sites, MachoSynthesizer twice and ElfSynthesizer twice, of which only one had
crashed; all four now share _syntheticSpan(). PeSynthesizer has the shape but is
benign, its ceiling always clears the floor.

Unbounded allocation. SmdaConfig.MAX_IMAGE_SIZE has been 100 MB all along and every
loader enforces it, but the synthesizers - which build images rather than load them
- never got the same guard, so functions 1 GB apart produced a 1 GB bytearray.
Repeated across fuzzing iterations that is what walked RSS past libFuzzer's limit.
The check goes in _resolveFunctionOffsets, the one path all three formats share.

A derived entry point. base_addr and oep can each sit inside the address space
while their sum does not, so ElfSynthesizer keeps its own guard: that value comes
from the report indirectly, and the deserialization bound structurally cannot see
it.
Not a synthesis bug despite the target that found it: escapeBinaryPtrRef is reached
from SmdaReport.fromDict via getPicHash, so any consumer importing a report can hit
it.

The operand regex accepts a hex literal of any length. Both escapeBinaryPtrRef and
escapeBinaryValue pack it as "<I" and fall back to "<Q" on struct.error, with
nothing behind that second attempt, so a value at or above 2**64 raises out of the
fallback itself. Both now return the sequence unescaped - a displacement that wide
cannot appear in the instruction bytes, so there is nothing to wildcard.

Masking the value to the pack width would read cleaner but changes pic_hash for
reports that work today, and pic_hash is a compatibility surface.
SmdaConfig is class-attribute based, so API_COLLECTION_FILES was one dict shared by
every instance in the process: one config registering an ApiScout database wrote
through to the class and leaked into every other config alive alongside it. That is
precisely SMDA's batch and service deployment shape.

The package also installed no NullHandler, so a consumer that never configures
logging got handler-of-last-resort output on stderr.

tests/testPackageMetadata.py additionally pins the two hand-maintained version
strings together - they are written into every report and drive the
recalculate-on-import decisions, with nothing previously checking they agree.
Eight methods - addGapCandidate, addTailcallCandidate, addReferenceCandidate,
addLanguageSpecCandidate, addPrologueCandidate, addSymbolCandidate,
addExceptionCandidate and locateLangSpecCandidates - re-declared their own parent's
body character for character. This is the copy-paste-between-arch-families shape
that sibling_check.py exists to catch in a diff, sitting in the source instead.

Identified by comparing parsed bodies, not by eye: six other overrides in the same
class (__init__, init, nextGapCandidate, checkFunctionOverlap, ensureCandidate,
locateCandidates) genuinely differ and are untouched. Dispatch is on self, so a
parent body calling back into the subclass still reaches the intel override.

Verified output-identical: report identity hashes, function counts, block counts and
instruction counts across cutwail, asprox, bashlite, komplex, mirai_i386 and
mirai_x64 are unchanged. Unifying the FunctionAnalysisState hierarchy is a real
refactor and is deliberately not part of this.
A previous commit switched six writes in evaluate_runtime.py to explicit utf-8 and
left every matching read alone, including the cache file it had just started writing
as utf-8. On Windows those reads use cp1252 and fail on any non-ascii byte, which the
emoji-bearing markdown these scripts emit guarantees.

Swept the class rather than the instance: the remaining benchmark-script sites in
both directions, SmdaReport's toFile/fromFile, BatchProcessor's per-sample report
writes, WinApiResolver's ApiScout database read, and the fixture manifests the tests
read. Report content is caller-supplied - filenames, symbol names - so none of it is
safe to leave locale-dependent now the test matrix includes windows-latest.

Note the reads matter more than the writes here: json.dump defaults to
ensure_ascii=True, so SMDA's own report files are pure ascii. A report written by
another tool, or the markdown these scripts emit, is not.
…s floor

Three changes to the fuzzing setup that only make sense together.

The oracle was permissive. ExceptionHandling's operational/non-operational split is
the library contract - degrade gracefully for the caller - and fuzzing/targets.py
added only RecursionError on top, so TypeError, AttributeError, KeyError,
IndexError, struct.error and friends were swallowed. Those are precisely how most
real Python defects present. The stricter oracle lives in fuzzing/, not in src/, so
the library contract is untouched, with a narrow documented allowlist.

A synthesis target. src/smda/synthesis/ is ~1.9k lines writing binary headers from
report-derived integers, documented as unhardened, with 14 tests and no fuzzing. The
target runs a fuzzer-built report through synthesizeBinary() for each format and
asserts the bytes re-parse with LIEF - a synthesizer whose output no longer loads has
failed at the one thing it is for. It found every synthesis and escaper bug in this
branch.

A committed corpus floor. The accumulating corpus lived only in the Actions cache,
which evicts after 7 days without a hit, so a quiet fortnight silently reset coverage
progress to bare seeds. tests/fuzz_corpus/ now holds a 212 KiB pruned floor,
XOR-obfuscated with the same scheme as the malware fixtures because most entries
derive from them; the cache stays the accumulating layer on top.

tests/fuzz_regressions/ had a documented convention and zero entries. It now holds
the reproducers for the bugs found here, each verified to bisect its fix rather than
merely pass.
Three gates that were either absent or reporting success without checking.

Coverage was configured but never measured: branch coverage and an exclude list
existed, with no fail_under, no --cov anywhere in .github/, and ignore_errors
silently swallowing measurement errors. Nobody knew the number. It is now collected
on one matrix leg, published to the step summary, floored at 75 (measured 81), and
gated on diff coverage - a global floor on 24.7k lines barely moves when a new module
lands untested, which is the case that matters. The omit glob *lib* is narrowed to
*/lib/*.

Tests ran on Linux only, across all four Python versions; Windows and macOS appeared
only in a job that imports smda and prints versions. For a library that parses PE and
Mach-O, platform breakage would ship unnoticed. Both now run the real suite on the
3.11 leg. They passed first time, which the utf-8 sweep in this branch is what made
true.

Supply chain: amannn/action-semantic-pull-request ran from a floating tag under
pull_request_target, i.e. with write-capable context, while every other action was
SHA-pinned. Pinned. A Security workflow adds pip-audit against the dependency set
resolved from pyproject - not the runner's environment, which drags in unrelated
tooling - and zizmor over the workflows, both on a weekly cron because a dependency
CVE lands without anything here changing. zizmor's one finding is suppressed inline
with a reason: that workflow never checks out or executes PR content.

pip-audit immediately earned its place. The setuptools bound in the Makefile and
README pinned contributors below the fix for PYSEC-2026-3447, which landed in 83.0.0;
pyproject already allowed it. Aligned.

SECURITY.md did not exist for a library whose entire purpose is parsing hostile input.
The perf gate failed only at a 50% slowdown, so four consecutive 15% regressions
could land green. There was also no memory channel at all, despite the README
documenting ~1.8 GB peak for a 3 MB binary and batch mode sizing its worker count by
per-file peak - the resource most likely to break a downstream consumer was
unmeasured.

run_perf_check.py now records peak allocation via tracemalloc, in its own pass so
tracing overhead cannot distort the timing channel beside it, and merge_results
accumulates it across counterbalanced passes like the timing samples. compare_perf
gates it at 10%: allocation totals barely vary run to run, so it can be far tighter
than wall-clock.

Lowering the time threshold to 30% then made the gate fire on its first real PR:
komplex went 0.2148s -> 0.2829s, +31.7%. It was noise - the overall row moved -0.9%,
three fixtures improved 6-11%, every report hash matched, and the new memory channel
showed komplex allocating 10.3 MiB on both sides, +0.01%. A fixture doing a third
more work moves its allocation; this one did not. The counterbalanced passes cancel
drift, not per-sample variance on a 0.2s workload.

So a fixture must now breach the percentage and lose at least 0.15s before it fails;
below that it reports WARN (NOISE?). The overall row keeps no floor, so a real
across-the-board regression still fails.
An installed smda handed consumers - MCRIT included - zero types despite CI
type-checking the source. src/smda/py.typed now ships (verified present in the built
wheel) and the public entry point in Disassembler.py is annotated explicitly. Most of
the remaining surface is inferred rather than annotated; the model classes are a
deliberate follow-up rather than a broad sweep.

ty now also checks fuzzing/, profiling/ and .github/workflows/scripts/ - the last of
which holds evaluate_runtime.py, 955 lines of statistics gating the perf benchmark
that nothing but its own tests had ever checked. profiling/ needs an
unresolved-import override because its imports live in the optional profile extra,
which the lint job deliberately does not install. tests/ is left out on purpose: it
carries ~187 diagnostics and needs its own pass.

Ergonomics: filterwarnings=error, so a DeprecationWarning from lief or capstone on
3.14 stops passing silently; a Hypothesis profile with deadline=None, since the
default 200ms wall-clock deadline against max_examples=500 fails correct examples on
a loaded runner; and a slow marker on the corpus-driven modules, which turns a ~150s
suite into a ~15s subset for iteration.
The release history had forked. README carried 87 entries and version_history.md
carried 119, and they were not a current-vs-stale pair: 71 versions appeared only in
README, 94 only in version_history, 15 in both. CHANGELOG.md merges them into 188
entries newest-first with README's wording winning on overlap, README keeps a pointer,
and version_history.md is gone. AGENTS.md's release procedure is updated to match,
since it directed editing the README section that no longer exists.

Also in AGENTS.md: it claimed a Python 3.10 target while pyproject is 3.11, and told
agents to run tests through .devcontainer/run.sh, which is git-ignored and absent.

`make clean` never deleted anything - its regex has an unclosed group, so grep errored
out every time, and it piped to unquoted xargs. Replaced with a find -exec that
survives paths with spaces.

requirements.txt was consumed by nothing, omitted hypothesis, ty and diff-cover, and
could only drift further; removed in favour of the dev extra. And a stray acute accent
sat alone on a line in the README.
mapBinary derived the header-copy length from the lowest PointerToRawData
above a 0x200 floor, but tested it with a strict `>`. 0x200 is the most
common PointerToRawData there is - it is what FileAlignment 0x200 produces
for the first section - so that section never lowered min_raw_section_offset
and the 0xFFFFFFFF sentinel survived. header_copy_len then collapsed to
len(binary) and the entire raw file was copied to RVA 0.

The per-section copies that follow only repair section-backed RVAs, so
everything between SizeOfHeaders and the first section's virtual address kept
raw file content where Windows maps zeros. The floor exists to reject
raw_offset == 0 (BSS-style sections), which `>=` still does.

Affects nearly every PE built with FileAlignment 0x200, including the bundled
njrat and pe_export_label fixtures and PeSynthesizer's own output.
getCodeAreas built each executable area from section.virtual_size alone. The
`% 0x1000` round-up is a no-op for 0, so a section with VirtualSize == 0
appended the degenerate area [start, start]. mergeCodeAreas preserves
zero-width ranges, which leaves code_areas non-empty - and that is what
decides the behaviour of BinaryInfo.isInCodeAreas and
FunctionCandidateManager._passesCodeFilter. Both leave their "no code areas,
assume the whole image is code" fallback and start filtering against a range
where `area[0] <= addr < area[1]` is false for every address, so analysis
reports status "ok" with zero functions.

On PE the field is also simply the wrong one: the loader maps such a section
by SizeOfRawData, and mapBinary already folds raw_size into the virtual
extent. That asymmetry is what proves the defect is in the gate - the image
maps correctly while the filter rejects all of it. The extent is now
recovered the same way, and no area is emitted unless it has positive width.

Swept the whole tree for the class; the remaining real siblings are fixed
here too:

* BinaryInfo.getSections (PE branch) yielded the same zero-width range, so no
  address resolved to the section's name in code_sections.
* aarch64 FunctionCandidateManager._peExecutableSectionRanges is the
  gate-on-size variant - it dropped the section from the executable-range
  list outright, rejecting every pointer target inside it.
* ElfFileLoader and MachoFileLoader getCodeAreas append the same degenerate
  area for a zero-size executable section or segment. Neither format has a
  SizeOfRawData analogue (sh_size is the size), so only the positive-width
  guard applies there.

DelphiPythiaProvider already uses max(virtual_size, size), PeSynthesizer
already handles the inverse case, and the ELF branch of the aarch64 helper
gates on virtual_address rather than size - recorded as benign, unchanged.
addCodeRef keeps code_refs, code_refs_from and code_refs_to as multi-edge
structures, but jump_targets is a flat set with no refcount. removeCodeRef
withdrew a single (from, to) pair from the three edge maps and then deleted
addr_to from jump_targets unconditionally, discarding every other jump that
still pointed there.

Every live caller passes a fall-through pair, and fall-through refs are
always booked with by_jump=False - so addr_to can only be in jump_targets
because some other instruction jumped to it. The purge therefore destroys a
real edge in one hundred percent of the cases where it fires. getBlocks()
rebuilds potential_starts from {start_addr} | jump_targets, so the surviving
branch target stops being a block start and its block is merged into the
predecessor.

Tracking the jump edges themselves and releasing the target only once no
jump edge remains fixes it. Five instances were measured on the committed
osx.gimmick sample.

This is the architecture-neutral base, so it fires on both backends; the CIL
and Dalvik states carry their own copies of the same code and are fixed
alongside it. The AArch64 caller has no max_instruction_start precondition at
all, so nothing was limiting the damage there.
END_INS was ["ret"], so getBlocks() walked straight through throw, rethrow,
endfinally and endfilter. The disassembler already treats all four as
terminators - it calls setNextInstructionReachable(False) for each - but the
block model kept its own, shorter list, and nothing reconciled them.

It only misbehaves when the following instruction is absent from
code_refs_to, and that is exactly a try, handler or filter entry:
_seedExceptionHandlerBlocks adds those offsets to jump_targets only. The
first break needs an outgoing ref the terminator never emits and the second
needs the successor to be a known ref target, so neither fires. The walk
falls through into the handler while the handler also opens its own block,
the same instructions land in two blocks, and num_instructions and num_blocks
are inflated - the duplicated instructions are hashed twice in
getPicHashSequence and getOpcHashSequence.

Both reachable shapes are ordinary compiler output. A try block ending in
throw is plain csc output and puts njrat at 49 block-instructions over 44
distinct ones. A filter clause (C# `catch ... when (...)`) places the catch
handler entry immediately after endfilter; adding endfinally and endfilter
alone takes njrat from 8454 block-instructions to 8453.
Two ways a single crafted or truncated .NET module aborted the entire
analysis instead of degrading.

DnfileMethodBodyReader.__init__ resolves pe.get_offset_from_rva(row.Rva).
That call is evaluated in the argument list of the CilMethodBody call, so it
runs inside a try whose only handler is MethodBodyFormatError. pefile's
PEFormatError derives straight from Exception and shares no ancestor with it,
so the handler cannot catch it, and nothing validated row.Rva - the guard
above checked only ImplFlags and Flags. One bad row ended the per-method loop
and discarded every method after it. Rows with a zero Rva are now skipped
like the other bodyless rows, and the handler matches the one in
CilSymbolProvider.update, whose comment already notes this is trivially
reachable from a crafted MethodDef row.

read_dotnet_user_string called pe.net.user_strings.get() under a handler for
UnicodeDecodeError alone, but dnfile initialises user_strings to None and
only replaces it when a stream literally named "#US" maps to UserStringHeap.
Renaming that stream is enough to raise AttributeError out of format_operand,
through analyzeFunction, and out of analyzeBuffer. The metadata-table path in
the same function already degrades to InvalidToken via getattr; the #US heap
now does the same.
getJumpTargets used one table_base variable for two different addresses: the
address entries are read from, and the anchor those entries' offsets are
added to. The reverse scan runs high-address-first, so the REG+REG add branch
sets table_base to the adr anchor and the load branch, which is visited
afterwards because it precedes the add in program order, overwrites it with
the table's own address.

Entries were therefore read from the right place and rebased on the wrong
one. On the committed Mach-O switch fixture every target is off by
(table - anchor) = 0xE8: all sixteen real case bodies are lost and four words
of table data are queued as basic blocks instead. Keeping the anchor
separately fixes it, and the ldr-only relative form - where no anchor is ever
recorded and offsets really are relative to the table - still falls back to
table_base.

The existing test only counted successors, so it passed throughout; it now
asserts the sixteen case-body addresses.
disassembleUnmappedBuffer builds its loader with map_file=True, so
binary_info.base_addr and binary_size describe the mapped image, but it went
on to hand the raw file_content to _addStringsToReport and to store it as
report.buffer.

StringExtractor.read_bytes computes rva = va - base_addr and indexes that
buffer, so image-VA arithmetic was applied to raw file offsets while
isAddrWithinMemoryImage still validated against the mapped size. Addresses
passed the range check and then read the wrong bytes. The persisted buffer
had the same problem, which defeats the buffer[addr - base_addr] carving
STORE_BUFFER exists for.

disassembleFile already used the mapped image; both other entry points now
match it.
LegacyDemangler.demangle counts elements with sanity_check() on the untrimmed
string and only afterwards truncates at ".llvm.". When that annotation sits
inside a length-covered segment and the remainder is all hex digits, the
count outlives the text it was derived from: the extraction loop iterates
past the end of the trimmed string and int() is handed an empty length
prefix.

The resulting ValueError is in neither RUST_DEMANGLE_ERRORS nor
NON_OPERATIONAL_EXCEPTION_TYPES, so it escapes both is_rust_language_evidence
and demangle_itanium_symbol. The live path is
ElfSymbolProvider._formatSymbolName, reached from setBinaryInfo ->
getExportedFunctions inside analyzeBuffer, which runs before any
label-provider guard - so one malformed symbol name turned the whole run into
an error report.

Raising UnableToLegacyDemangle when no length prefix remains puts it back in
the demangler's own error set, where the existing guards handle it.
str.isdigit() is True for every character with the Unicode digit property,
including category No - RUMI DIGIT ONE, superscripts, circled digits - but
int() only accepts category Nd. Every isdigit()-gated parse that then calls
int() therefore raises ValueError on input the gate just accepted.

That ValueError is in neither RUST_DEMANGLE_ERRORS nor
NON_OPERATIONAL_EXCEPTION_TYPES, so it escapes is_rust_language_evidence and
demangle_itanium_symbol exactly like the empty-length-prefix escape fixed in
the previous commit - one symbol name aborts the entire run.

Six sites across both demanglers shared the class: the legacy length-prefix
scan and its sanity_check counterpart, and the v0 parser's hex_nibbles,
digit_10 and digit_62. Mangled Rust symbols are ASCII by construction, so
each gate becomes an explicit `in string.digits` test; both modules already
imported string for the neighbouring hexdigits checks.

Found by the existing hypothesis fuzz target, which reached
"_ZN\U00010e60" once its example database explored that far - the targeted
regression test for the previous fix could not have covered this, since the
guard it added is for a different condition on the same line.
The diff-coverage gate requires 100% on changed lines under src/smda/**, and
five branches introduced by the preceding commits had no test reaching them:

* the CIL and Dalvik copies of the refcounted jump-edge removal - only the
  shared base was covered, which is precisely the asymmetry that let the same
  bug survive in three places to begin with;
* the AArch64 jump-table anchor captured from the *second* add operand
  (`add Xd, Xindex, Xanchor`), the arm the committed fixture never takes;
* the executable-range guard for a section whose VirtualSize and
  SizeOfRawData are both zero;
* STORE_BUFFER on disassembleBuffer, where the stored buffer is the caller's
  own already-mapped bytes rather than a re-read image.
CI runs ty over src/smda, fuzzing, profiling and the workflow scripts in its
Code Quality job, but no single command reproduced that locally, so the check
was easy to skip until CI failed. The target runs the same four paths as the
job, so a local run and CI cannot disagree; tests/ is excluded in both.

Note that ty resolves imports from the discovered project environment
(./.venv) rather than from $(PYTHON). A local venv carrying extras that CI
does not install can therefore hide an unresolved-import error that fails the
job, so pass --python at an environment without those extras to reproduce CI
exactly.
The correctness fixes on this branch left twenty-one comment lines across
src/, against a project convention of no comments in code. Most restated the
line below them: "a zero-width area is never a useful bound" above
`if section_end > section_start`, or "skip methods that do not have a method
body" above a test for a missing Rva and IL flag.

Six lines survive, each recording something the code genuinely cannot say on
its own: the PE loader's rule that a zero VirtualSize section is mapped by its
raw size, the ordering dependency that keeps each malformed report field
reporting itself, why a legacy Rust element count can outlive its length
prefix, and why a displacement or immediate wider than 64 bits needs no
escaping.

The VirtualSize rule was stated twice, in PeFileLoader and again in the
AArch64 candidate manager; only the loader's copy remains, so the two cannot
drift apart. What is gone throughout is rationale for why an edit was made,
which belongs in history rather than in the source.

No behavior changes. Full suite green.
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.

1 participant