A single-file bundle manifest is attacker-controlled. Opening a compressed
entry pre-allocated a MemoryStream of the declared decompressed size
through an unchecked long-to-int cast, then inflated the whole deflate
stream before comparing lengths. A few-byte payload declaring ~2 GB thus
forced a ~2 GB allocation up front, sizes at or above 2 GB wrapped to a
negative capacity, and a decompression bomb was expanded in full before
the mismatch was noticed (CWE-789, CWE-197).
Grow the buffer only with bytes the deflate stream actually produces and
stop reading one byte past the declared size, which already proves the
entry corrupt. Reject declared sizes that cannot fit a single in-memory
buffer as invalid bundle data. Entry offsets need no extra check: the
UnmanagedMemoryStream over the mapping already validates them against the
view length.
Assisted-by: Claude:claude-fable-5:Claude Code
Searching a package resolves its entries by file name against a case-insensitive
cache, which is right for an assembly reference but wrong for an archive entry:
two entries differing only in case are two files, and they collapsed onto one
LoadedAssembly, so one was searched twice and the other never. Keying the cache
by the entry itself separates them, and the entry's package-relative path
becomes the assembly's file name, which is what tells the copies of one assembly
in a multi-target package apart wherever a search result shows a location.
Cancellation was only checked between top-level list entries, so a walk the user
had already replaced by typing another character kept extracting package entries
alongside the run they were waiting for. The omnibar had no way to end its run at
all: its view model is per document tab and nothing cancelled it when the tab
went away.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Resolving a type to its tree node scanned every descendant of the root, which
means every namespace node of every assembly - all of them built eagerly - to
find the one assembly node it needed. A package child records the bundle it
came from, so that chain leads straight to the single top-level node worth
descending into, and only that package's folders are searched from there.
The two sibling lookups only ever considered the root's direct children, so
neither resolved anything inside a package at all. Sharing one helper fixes
them along the way, and it expands package folders on the descent because
search surfaces package contents whether or not the tree was ever opened
there.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Searching inside bundles and packages means expanding them, and the expansion
has to await each assembly's load result - which is what triggers the lazy
load in the first place. Building the full list up front (as the WPF pane did)
therefore means a search on a freshly restored list produces nothing at all
until the last assembly is off disk, and the blocking wait for it ignored the
cancellation token the pane fires on every keystroke.
The snapshot is still taken eagerly, before the first element is yielded, so
the set cannot change under a running walk; a failing assembly or an
unreadable package entry skips itself rather than abandoning the rest.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
* Set v11 RTM
* Update features in README.md
* Remove the two 900-iteration process-list scroll tests
* Keep Svg.Controls.Skia.Avalonia at 12.0.0.13
* 10.0.11 and Roslyn for net11p7
* Fix module-scan test failing when PowerShell's NGen images are stale
* Opt Pack NuGets out of the MSBuild server to fix SBOM generation
The analyzer panel could only walk downward (Overridden By, Implemented
By); finding what a member overrides required manually climbing base
types. The upward interface direction already exists as the Implements
analysis, so this adds the missing base-class direction as its dual.
The analyzer walks the override chain member by member via
InheritanceHelper.GetBaseMember instead of collecting all
signature-equal base members, so it stops at 'new virtual' shadow
boundaries, which hide any further base members from overriding. No
assembly-scope scan is needed; the walk is a direct type-system lookup.
Assisted-by: Claude:claude-fable-5:Claude Code
ILSpyX had no instrumentation, yet most UI-visible latency bottoms out
here: lazy assembly loads, the first-resolve cascade that metadata-loads
every assembly in a list snapshot, per-module search strategy runs,
analyzer scope scans over all assemblies and their references, bundle/zip
entry extraction, and PDB loading. The provider mirrors the
ICSharpCode.Decompiler design: Start/Stop pairs, keyword gating, and
IsEnabled() guards at every call site; per-entry package extraction is
Verbose because of its volume.
AbstractSearchStrategy.Search is now a non-virtual template method that
wraps the span around a new protected SearchCore, so derived strategies
cannot bypass the instrumentation.
Assisted-by: Claude:claude-fable-5:Claude Code
Pre-checking stream.Length before reading is racy (the stream can shrink
between check and read) and not every stream knows its length ahead of
time. Instead, catch EndOfStreamException at each read: a stream too
short for the magic is passed through as not-XALZ, and a truncated
header or payload after a confirmed magic is rejected as
InvalidDataException. stream.Length remains only a sizing hint for the
payload buffer.
Assisted-by: Claude:claude-fable-5:Claude Code
The Xamarin XALZ loader sized its buffer allocations from an attacker-controlled
header field and ignored the partial-read length, so merely opening a crafted
file (the loader is registered first and runs on any XALZ-magic input) could
crash or over-allocate. The declared uncompressed length, a raw header uint cast
to int, had no sanity bound: a tiny file claiming ~2 GB forced a giant
ArrayPool.Rent (decompression-bomb amplification), and a high-bit value became
negative and made Rent throw ArgumentOutOfRangeException. The compressed length
was taken as the whole file (header included) and ReadAsync's return value was
discarded, leaving stale pooled bytes in the tail fed to the decoder; the
output MemoryStream then exposed the entire rented buffer, so PEFile parsed past
the real decompressed data into leftover pool contents.
Bound the declared length before renting (reject zero, > int.MaxValue, or more
than an LZ4 block could expand from this payload at its 255x maximum ratio),
read the payload that actually follows the header with ReadExactlyAsync, and
slice the output to the length LZ4Codec.Decode reports. Malformed input now
fails as a catchable InvalidDataException, consistent with the bundle and .rsrc
hardening; well-formed Xamarin modules load exactly as before.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Backfills the standard MIT X11 header on hand-written files that never
got one, attributing each to its first-commit author and year from git
history. Code vendored from dotnet/runtime and Humanizr/Humanizer gets
its origin's license lines and a provenance note instead. Generated
files (Resources.Designer.cs, the version-info template), tool-managed
suppression files, and BAML test-case fixtures intentionally stay
header-less.
Assisted-by: Claude:claude-fable-5:Claude Code
ILSpy resolves an assembly's references against the target framework it
detects from the TargetFrameworkAttribute. When that attribute is missing,
wrong, or the user wants to force a different framework, there was no way to
hint the correct one, so references could resolve against the wrong runtime
pack or framework directory.
A LoadedAssembly can now carry a TargetFrameworkIdOverride that short-circuits
detection (it is the single value every LoadedAssembly-based resolution path
reads), is persisted in the assembly-list XML, and is carried across a reload
so a runtime change re-resolves against the new framework. The "Set Target
Framework" context-menu entry edits it through a dialog with a free-form text
box and an always-visible list of common monikers to pick from (the app forces
overlay popups, so a dropdown would be clamped inside the small dialog); input
is validated and converted from the short TFM users know (net48) to the long
FrameworkName form the resolver consumes (.NETFramework,Version=v4.8) via
NuGet. The direct DetectTargetFrameworkId callers in the decompiler core
(project export, language version) intentionally keep reading the real
attribute; only reference resolution is overridden.
Resurrects a 2020 prototype (branch tfmoverride) re-implemented against the
current ILSpyX/Avalonia code, whose surrounding structures no longer matched.
Assisted-by: Claude:claude-opus-4-8:Claude Code
BookmarkManager carried a byte-for-byte copy of ILSpySettings' private
MutexProtector, both guarding read-modify-write of a config sidecar in the same
settings directory so parallel ILSpy instances fold their edits in turn instead
of clobbering each other. Promote it to a single public type in
ICSharpCode.ILSpyX.Settings so the locking semantics live in one place.
Assisted-by: Claude:claude-opus-4-8:Claude Code
ICSharpCode.Decompiler / ILSpyX / BamlDecompiler set RestoreLockedMode but
declared no RuntimeIdentifiers, so their lock files were RID-agnostic. A
RID-specific 'dotnet publish -r <rid>' of ILSpy (which declares all four
distribution RIDs) propagated the RID to these locked project references and
failed restore with NU1004 unless --no-restore or --force-evaluate was used.
Declare the same win-x64;win-arm64;linux-x64;osx-arm64 set on the three
libraries and regenerate the locks so a locked-mode publish works for every
platform out of the box.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The Microsoft.DiaSymReader / .PortablePdb packages pull NETStandard.Library
1.6.1, which drags in 4.3.0 builds of System.Net.Http, System.Private.Uri
and System.Text.RegularExpressions, all carrying known advisories (NU1902/
NU1903). They are framework-provided at runtime on net10.0; pin the patched
4.3.4 / 4.3.2 / 4.3.1 via central transitive pinning so NuGet audit passes.
Enabling transitive pinning also aligns a handful of other transitive
packages to their declared central versions.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The UseNestedNamespaceNodes and HideEmptyMetadataTables handlers both
force a materialised node to re-create its children (clear, re-arm
LazyLoading, EnsureLazyChildren), guarded on it not already being lazy.
That's a general tree-node operation -- a soft, in-place rebuild that
re-runs LoadChildren without reloading the assembly -- so it belongs on
SharpTreeNode next to the lazy contract it builds on, not as a private
helper in the model. Both call sites now use node.ReloadChildren().
Assisted-by: Claude:claude-opus-4-8:Claude Code
Replace the Windows-only .bat helpers (clean / debugbuild / releasebuild /
restore / updatedeps and BuildTools/format) with cross-platform pwsh
scripts at the repo root: restore.ps1, build.ps1 (-Configuration), clean.ps1,
updatedeps.ps1 and BuildTools/format.ps1, alongside the existing publish.ps1.
Enable a packages.lock.json for every project by hoisting
RestorePackagesWithLockFile into the root Directory.Build.props (the four
core libraries set it individually before) and commit the generated locks,
so restores are repeatable and CI can cache packages off them.
Cache the NuGet packages folder in the three setup-dotnet workflows
(build-ilspy, build-frontends, codeql-analysis), keyed on the lock files
per the setup-dotnet caching guidance.
Scope the Debug "Verify package contents" check to the *.filelist outputs
it actually generates. A project's packages.lock.json is keyed only by
(framework, RID), with no host-OS axis, so a lock produced on Linux
legitimately differs from one produced on Windows whenever an OS-conditional
PackageReference applies (Debug+Windows pulls Microsoft.DiaSymReader*). The
Windows restore then rewrites those locks; that churn must not fail a step
whose job is to police the VSIX/MSI file lists.
Also drop the dead ILSpy.BamlDecompiler publish line from
publishlocaldev.ps1, mirroring the earlier publish.ps1 fix.
Assisted-by: Claude:claude-opus-4-8:Claude Code
NodesInserted/NodesRemoved reported a multi-node change (a node plus its visible
descendants -- a contiguous run) as a sequence of single-item Add/Remove events,
but the underlying tree updates Count by the WHOLE run before they fire. A
consumer that reconciles the change one item at a time and re-indexes the source
mid-sequence then reads against a Count that has already dropped by the full run:
Avalonia's SelectionModel does exactly this (it re-reads SelectedItems while
handling each Remove), indexing past the end and throwing ArgumentOutOfRangeException
out of TreeFlattener's indexer whenever a selection was live during a bulk reshape
(e.g. toggling UseNestedNamespaceNodes). It only surfaced under full-suite timing,
which is why it read as flaky.
Raise one ranged event for the whole run instead, so the notification matches the
tree's state in a single step and Count/indices stay in agreement. The indexer
keeps throwing on genuine out-of-range access. Avalonia 12.4's virtualization and
selection handle ranged Add/Remove (every expand/collapse exercises it); full
headless suite is green.
Unload / Clear / ReloadAssembly / HotReplaceAssembly all called LoadedAssembly.Dispose() on the assembly they removed, which disposes its MetadataFile and unmaps the underlying file. But open document tabs and tree nodes can still hold that MetadataFile, and the list has no safe point at which to know those references are gone -- so disposing risked unmapping a file out from under a live reader (use-after-dispose). Drop the removed assembly and let the GC reclaim it once nothing references it instead. The DockWorkspace cancel-on-remove is now a courtesy, not a guard against an unmap race.
Two first-run behaviours that the previous version had and the port had
lost. When a launch has nothing to restore (no saved tree path, no
command-line target), greet the user with the About page in the main
tab instead of an empty "(no selection)" view; the greeting is fired
after the tree is ready and only takes the main tab when it's still
empty and active, so it can't steal activation from a tab the user
opened meanwhile (e.g. Compare...). And seed the default list with the
.NET framework ILSpy itself runs on - every managed assembly in the
running runtime's shared-framework directory. Headless tests opt out of
that seed (re-opening ~150 assemblies per test is ~10x slower) and fall
back to the minimal trio, leaving their expectations unchanged.
Assisted-by: Claude:claude-opus-4-8:Claude Code
When the user removes an assembly while a decompile is still in flight,
the title-refresh paths (spinner tick, post-Task.Run InvokeAsync,
StopSpinner) walk node.Text -> LoadedAssembly.Text ->
metadata.GetAssemblyDefinition().Version, reading directly from the
PE file's MemoryMappedFile. AssemblyList.Unload calls Dispose
synchronously and unmaps that file; any continuation that lands on the
UI thread after Dispose dereferences freed pages and the CLR reports
the AV through FailFastIfCorruptingStateException -- a catch block
cannot intervene in .NET 5+.
Assisted-by: Claude:claude-opus-4-7:Claude Code
The default ApiVisibility=PublicOnly setting filters search results
through CheckVisibility, which rejects private entities. Compiler-
generated names — async/iterator state machines (<Method>d__N),
display classes (<>c__DisplayClass), anonymous-method closures (<>c)
— are all private, so they were unfindable even when the user
typed the exact full name.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Port all five analyzer test files from ILSpy.Tests/Analyzers (the
WPF-side test project that exercises the shared ICSharpCode.ILSpyX
library) into ILSpy.Avalonia.Tests/Analyzers/Library. The analyzer
logic itself is platform-agnostic, so the tests run unchanged once
the test assembly can reach the library's internals.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Two ordering fixes for startup so the user sees the assembly tree before any
decompiled content shows up in the document area, and so the lazy-load story
actually holds.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Replace LoadedAssembly's eager Task.Run(LoadAsync) with a Lazy<Task<LoadResult>> so
construction is free and the load only starts on the first GetLoadResultAsync /
await / .Result access. Active assembly's load fires from the saved-path restore
and runs without competition; everything else stays cold until either the user
expands a tree node OR the cooldown sweep fires.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Move IResourceNodeFactory to ILSpyX.Abstractions and GuessFileType to ILSpyX.Util
so both UIs can consume them. Wire ResourceEntryNode.Create(Resource) to walk the
MEF-discovered IResourceNodeFactory list (falls back to the generic node), make
Avalonia's ILSpyTreeNode implement ITreeNode for that contract, and register a
ResourcesFileTreeNodeFactory to keep .resources files routed correctly. Adds the
Save virtual on ILSpyTreeNode plus inline "Save" buttons + SaveAsync on
ResourceEntryNode/ResourceTreeNode using a new FilePickers helper around
StorageProvider.SaveFilePickerAsync.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Strip the WPF ILSpy/, ILSpy.Tests/, and ILSpy.BamlDecompiler/ projects
and their solution entries so this commit can serve as the base for
replaying the rewritten Avalonia history on top. The BAML plugin needed
UseWpf + AvalonEdit and had a ProjectReference to ILSpy/ILSpy.csproj;
once both are gone it cannot build and would block bisect for every
intermediate commit. The Avalonia main app will absorb its three exports
(factory, file handler, entry node) in the later "BAML to XAML
decompiler integration" commit.
MetadataFile now declares IDisposable using the canonical pattern
(public non-virtual Dispose() + protected virtual Dispose(bool)).
PEFile and WebCilFile become sealed and override Dispose(bool) to
release the PEReader and MemoryMappedViewAccessor they own;
ResourcesFile is also sealed. PortableDebugInfoProvider disposes the
MetadataReaderProvider it owns. LoadedAssembly implements IDisposable
and disposes both the loaded MetadataFile and the debug-info provider.
AssemblyList.Unload / Clear / ReloadAssembly / HotReplaceAssembly now
dispose the LoadedAssembly instances they evict, fixing a resource leak
where every "Reload Assembly" held the previous PEReader (and the
underlying file handle / memory-mapped view) alive until GC eventually
finalized it.
The disposal contract terminates at the AssemblyList tier: downstream
holders of MetadataFile (MetadataModule, DecompilerTypeSystem,
AssemblyListSnapshot, ...) hold borrowed references rather than owned
ones, so making the base IDisposable does not cascade into CA1001 /
CA2213 warnings elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>