Skip to content

CLR-accurate .NET Framework binder for net48 roots#154

Merged
willibrandon merged 4 commits into
mainfrom
fix/153-net48-gac-and-binding-redirects
Apr 25, 2026
Merged

CLR-accurate .NET Framework binder for net48 roots#154
willibrandon merged 4 commits into
mainfrom
fix/153-net48-gac-and-binding-redirects

Conversation

@willibrandon

Copy link
Copy Markdown
Owner

For a net48 EXE whose bin folder has no *.deps.json and no mscorlib.dll, the existing probe chain misses the GAC, the framework runtime directory under Microsoft.NET\Framework[64]\v4.0.30319, and every <bindingRedirect> declared in app.config — so the dep graph fills with ? and ! leaves that don't reflect what the CLR actually loads.

This adds a NetFxBinder that walks the real .NET Framework binding order: framework unification, machine.config, publisher-policy assemblies in the GAC, app.config redirects, then locate via the GAC, the framework runtime directory, configured <codeBase> hrefs (fail-fast), and finally the application base plus <probing privatePath> with culture-aware probing. The four policy layers chain sequentially, so a 1.0 → 2.0 app redirect followed by a 2.0 → 3.0 machine redirect ends at 3.0; a runtime-scoped <publisherPolicy apply="no"/> suppresses publisher policy across the AppDomain. mscorlib gets a fast path to the framework runtime directory because the CLR special-cases the bootstrap assembly even when a GAC copy exists.

Dep graph nodes for net48 roots are keyed on the bound (post-policy) identity, so two upstream refs at different versions that both redirect to one loaded version collapse onto a single node — and each GraphEdge keeps its pre-redirect RequestedIdentity for diagnostics. A new glyph marks redirected nodes; × marks a <codeBase> whose href does not exist on disk, and the configured href rides through to GraphNavigationContext so the status line surfaces it instead of (unknown).

The same NetFxBindingContext flows through every resolution surface — dep graph builder, ResolveAssemblyByIdentity, ImplementationAssemblyResolver, IL navigation, General-tab drill-in — so every tab agrees on what the runtime would load. The type-forwarder chase now reads the next hop's full identity (name, version, culture, PKT) from the current assembly's metadata, not from the original referencer's, so a forwarder pointing at a different version of an implementation lands correctly. .NET Core / .NET 5+ behavior is unchanged when NetFxBindingContext.TryBuild returns null.

samples/NetFxBindingRedirects (root EXE plus five helpers — OldDep, NewDep, PrivatePathLib, CodeBaseLib, CulturedLib) acts as a runtime oracle. Running the EXE with --oracle <path> writes a JSON map of Assembly.FullName / Assembly.Location for every interesting load, and the test fixture compares the binder's output against what the actual CLR loaded. The new tests use the test machine's real GAC and Framework[64] directory; no mocks. 950/950 tests pass.

Fixes: #153

#153)

Resolution for net48 EXEs now walks the actual CLR binder: framework
unification, machine.config, publisher policy assemblies in the GAC,
app.config <bindingRedirect>, then locate via GAC, the framework
runtime directory under Microsoft.NET\Framework[64]\v4.0.30319,
configured <codeBase> hrefs (fail-fast), and finally the application
base plus <probing privatePath> with culture-aware probing. Layers
chain sequentially so a 1.0 -> 2.0 app redirect followed by a 2.0 ->
3.0 machine redirect ends at 3.0; a runtime-scoped <publisherPolicy
apply="no"/> suppresses publisher policy across the AppDomain.

Dep graph nodes are keyed on the bound identity, so two upstream refs
at different versions that both redirect to one loaded version
collapse onto a single node. Per-edge RequestedIdentity records the
pre-redirect version. The new ↪ glyph marks redirected nodes; × marks
a configured <codeBase> whose href does not exist on disk.

The same NetFxBindingContext flows through every resolution surface
(dep graph builder, ResolveAssemblyByIdentity,
ImplementationAssemblyResolver, IL navigation, General-tab drill-in)
so all tabs agree on what the runtime would load. The forwarder chase
uses the *current* assembly's metadata to recover the next hop's full
identity, not the original referencer's. .NET Core / .NET 5+ behavior
is unchanged when no NetFxBindingContext is built.

samples/NetFxBindingRedirects (root EXE plus five helpers: OldDep,
NewDep, PrivatePathLib, CodeBaseLib, CulturedLib) acts as a runtime
oracle: --oracle <path> writes a JSON map of Assembly.FullName /
Assembly.Location for every interesting load so tests compare the
binder's output against what the actual CLR loaded.

Closes #153
@willibrandon willibrandon added dep-graph Dependency graph builder and Dep Graph tab net-framework .NET Framework (CLR 2 / CLR 4) target compatibility and resolution labels Apr 25, 2026
@willibrandon willibrandon self-assigned this Apr 25, 2026
@willibrandon willibrandon added dep-graph Dependency graph builder and Dep Graph tab net-framework .NET Framework (CLR 2 / CLR 4) target compatibility and resolution labels Apr 25, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc303b9f0f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Dotsider.Core/Analysis/NetFxBinder.cs Outdated
Comment thread src/Dotsider.Core/Analysis/Models/NetFxBindingContext.cs Outdated
…by appliesTo

ResolveCodeBaseHref stripped file:// without re-attaching the leading
\, so file://server/share/lib.dll was returned as the relative-looking
server\share\lib.dll and File.Exists treated it as a local relative
path. Switch to new Uri(href).LocalPath, which round-trips UNC and
drive-letter local URIs the way the CLR does.

NetFxBindingContext.ParsePrivatePaths re-walked the config XML without
honoring the appliesTo attribute on the surrounding <assemblyBinding>
block, so privatePath segments under e.g. appliesTo="v2.0.50727" leaked
into a net48 root's probe list. Drop the duplicate parser and reuse
BindingPolicy.ParseConfigFile, which already filters non-v4 blocks.
…e-dir table

The first-cut roll-forward in TryFrameworkRuntimeDir misreported the
location for unified binds (the file lives in the GAC, not Framework
[64]\v4.0.30319), accepted compatibility-pack PKTs the CLR doesn't
unify (System.ValueTuple, System.Memory family), and didn't update
Loaded.Version on the mscorlib fast path so callers saw the requested
version instead of the actually-loaded one.

Replace it with a per-identity unification table built once at
BindingPolicy.LoadFrom by scanning the architecture-correct framework
runtime directory and recording (Name, PKT) -> Version for every
in-box framework assembly. Apply consults this table first; matches
rewrite the effective identity in either direction so the GAC scan
locates the file at its real slot. Verified against live net48
PowerShell:

  Microsoft.VisualBasic v8.0.0.0 -> v10.0.0.0 from GAC_MSIL
  System.IO.Compression v4.2.0.0 -> v4.0.0.0 from GAC_MSIL
  mscorlib v8.0.0.0              -> v4.0.0.0 from Framework64
  System.ValueTuple v4.1.0.0     -> FileNotFoundException

Split the well-known framework PKT set in AssemblyAnalyzer into the
broader classification set (used by IsFrameworkAssembly for the
dep-graph filter) and the narrower unification set (only the in-box
BCL/tooling keys). Compatibility-pack tokens cc7b13ffcd2ddd51 and
adb9793829ddae60 stay in the classification set but are excluded from
unification, matching the CLR's behavior for those NuGet shims.
…architecture

Two functional-correctness fixes flagged in PR review:

1. The GAC walk that builds FrameworkUnificationTable was including any
   GAC entry whose PKT happened to match a framework public key token.
   Microsoft signs many non-framework assemblies (VS, SQL Server, Office
   PIAs, MSBuild tasks) with those PKTs, so Apply could rewrite a
   reference to the highest GAC version even when no real binding
   redirect was in play. Add LoadFrameworkAssemblyNames, which builds an
   allowlist from the Reference Assemblies tree
   (%ProgramFiles(x86)%\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.x
   plus its Facades subdir) — the canonical list of names that ship as
   part of .NET Framework. AddGacEntries now gates each candidate on
   that allowlist before recording it.

2. The same GAC walk scanned GAC_MSIL + GAC_64 + GAC_32 unconditionally,
   so a higher version installed in the cross-architecture bucket could
   land in the table at a path the locate stage never probes (locate
   only walks GAC_MSIL plus the root's matching arch). AddGacEntries now
   takes NetFxArchitecture and walks GAC_MSIL plus the matching slot
   only, so anything we record is reachable later.
@willibrandon willibrandon merged commit e190098 into main Apr 25, 2026
4 checks passed
@willibrandon willibrandon deleted the fix/153-net48-gac-and-binding-redirects branch April 25, 2026 19:50
willibrandon added a commit that referenced this pull request May 2, 2026
Same shape as #154 (the net48 binder), different probe locations for the
CLR 2 generation: %WINDIR%\assembly\{GAC_MSIL, GAC_<arch>, GAC} with
no-prefix <version>__<pkt> tokens, v2.0.50727 runtime + machine.config,
and the v3.5 + v3.0 reference-assemblies trees (covers WPF/WCF). Detection
runs off the mscorlib v2 AssemblyRef because pre-4.0 assemblies have no
TargetFrameworkAttribute, so the existing v4-prefix gate misses them.

A new NetFxRuntimeVersion enum threads through NetFxBindingContext,
BindingPolicy (eight methods), and NetFxBinder.TryGac. Public LoadFrom
and ParseConfigFile keep their existing call shape via a default Clr4
argument, so the v4 path stays byte-identical. DotsiderState now reads
RootNetFxBindingContext for IsNetFramework and exposes
EffectiveTargetFrameworkDisplay so a no-TFA root no longer shows
"(unknown)" and the Dynamic-tab EventPipe gate stays correct.

samples/NetFxBindingRedirects.Clr2 is the runtime oracle. SharedDep.V1/V2
share key + name so the redirect collapses through transitive UsesShared*
metadata edges (not standalone Assembly.Load). app.config pins to
v2.0.50727 so the oracle activates CLR 2 even on hosts with .NET 4.x
present, and exercises five appliesTo variants including a v4-only poison
block the binder must exclude. The fr satellite is built via the v3.5
framework csc.exe (the SDK's al.exe only emits CLR4 metadata); both the
satellite-build and the root EXE's satellite Copy are gated on Exists()
so Linux/macOS and Windows-without-.NET-3.5 skip cleanly.

Four new test files mirror the v4 suite. Synthetic temp-tree GAC and
publisher-policy tests run on any Windows host; live-runtime tests gate
on Clr2RuntimePresent. The staged SharedDep identity assertion catches
copy-local leaks since V1 and V2 emit identical filenames. 1024
Dotsider.Tests + 81 Mcp + 4 Website pass on all three CI runners.

Fixes: #158
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dep-graph Dependency graph builder and Dep Graph tab net-framework .NET Framework (CLR 2 / CLR 4) target compatibility and resolution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assembly resolver misses GAC and ignores app.config binding redirects on .NET Framework targets

1 participant