Skip to content

Revisit use of unsafe in generated code and in libraries #11467

Description

@simonrozsival

The codegen for the trimmable type map uses unsafe extensively and with the upcoming changes to the unsafe in C# we should make sure our unsafe code matches the new unsafe model.

Source audit and migration analysis

The primary concern is hidden caller obligations, not JNI's unavoidable use of native code. Some APIs expose unchecked native-memory operations through signatures that appear safe today. Other unsafe operations can be replaced with bounded managed APIs. The binding generators are a major migration dependency because their output becomes application and library source.

This analysis is a source-level review of managed runtime code, shared tooling, and generators under src/, build-tools/, and external/Java.Interop/ at 0205e8caae. It is not an exhaustive memory-safety audit. No production code was changed, and no builds, regression tests, crash reproductions, or new-model compilation experiments were run. The findings below are source-audit observations, not confirmed exploits.

Model and adoption constraints

The article describes an opt-in model planned for preview in .NET 11 and production in .NET 12, nominally C# 16. The current language proposal is evolving; the final project switch, syntax details, and BCL annotations must be verified against the compiler/reference assemblies selected for migration.

Under the proposed model:

  • Member-level unsafe expresses a caller-facing contract; it no longer establishes an unsafe context throughout the implementation. Unsafe operations need explicit interior contexts.
  • A safe-callable wrapper must discharge its callees' obligations. Adding an interior block without establishing bounds, initialization, ownership, and lifetime does not make it sound.
  • IntPtr, managed references, and spans can conceal unsafe obligations even without pointer types in their signatures.
  • Type-level unsafe is removed, and native declarations require explicit safety classification. The current proposal also requires field classification for explicit-layout types.
  • The compiler does not prove pointer validity, lifetime, ABI correctness, or the truth of safety documentation.

Upstream tracking: dotnet/runtime#125800.

Findings and removal opportunities

Priority Source evidence Observation and action
High: missing bounds guard JNIEnv.cs:487-493 NewString(char[]? text, int length) pins the array and forwards length without checking it against text.Length. JNI receives a pointer, not the managed array's capacity. Reject negative and oversized lengths before calling native code; preserve the existing null behavior deliberately. With those bounds and the pinning scope established, this managed-buffer overload should remain a safe boundary with an interior unsafe block.
High: unchecked native references JavaPrimitiveArrays.cs:856-862 All eight primitive-element indexers return ref Elements[index] after checking disposal, but not the index. Pointer indexing has no managed-array bounds check. Add element-count bounds checks in JavaPrimitiveArrays.tt, regenerate, and review the byte-size calculation for overflow.
High: escaping-reference lifetime Same indexers; JavaArray.cs:327-362 A returned reference can survive Release() or Dispose(). Checking disposal when acquiring the reference does not protect subsequent use. The existing byref indexer and raw Elements accessor need caller-unsafe lifetime contracts, including prohibition on concurrent release. Add value-returning read/write alternatives that keep storage live throughout each access. Bounds checks alone cannot make an escaping reference safe.
High: unchecked type reinterpretation JNINativeWrapper.g.tt:285-290 CreateBuiltInDelegate dispatches on delegateType.Name, then uses Unsafe.As<T>(dlg); the template produces 40 such calls. A matching simple name does not prove type identity. Generate type-pattern dispatch or exact type checks with checked casts, preserving the existing fallback for unsupported delegates.
Removal opportunity: managed-reference punning Files.cs:695-698 XorLength reinterprets the first byte of a span as ref ulong, relying on an eight-byte extent and alignment assumptions outside its signature. Replace it with bounded BinaryPrimitives reads/writes, preserving the current byte order and hash output. This is not an observed failure with current callers.
Removal opportunity: pointer-based encoding ScannerHashingHelper.cs:49-65 Managed strings and a managed span are converted to pointers solely for UTF-8 encoding. Use Encoding.UTF8.GetBytes(ReadOnlySpan<char>, Span<byte>) and destination slices; also make the combined byte-count calculation checked.
Contract audit: raw pointers hidden in handles JniRuntime.cs:180-198, Buffer.cs:8-14, JniRemappingLookup.cs:620-627 Runtime creation accepts raw VM/environment addresses, direct-buffer access exposes raw storage, and the remapping helper scans an IntPtr until a terminator. Document provenance, thread affinity, readable extent, termination, and lifetime as appropriate. A nonzero check cannot establish these properties. Propagate residual obligations at raw entry points, or discharge them through a controlled owner.

Another removal candidate is Crc64Helper.HashCore, which performs pointer indexing and raw ulong loads over managed arrays. A span-based implementation could validate the input range and use bounded reads/table indexing. Do not blindly substitute System.IO.Hashing.Crc64: the repository preserves a different legacy CRC variant and output convention.

The production source/template scan found no <safety> or // SAFETY: documentation and no SkipLocalsInit declarations. The bounded, populated span-based stackalloc and ArrayPool patterns inspected are not, by themselves, findings.

The existing correctness concerns should receive separate linked bug issues and regression tests rather than wait for the .NET 12 migration. They are included here to inform boundary design; this analysis does not assert that they have been reproduced.

Nine-step migration checklist

  1. Fix boundary defects before changing compiler semantics. Address unchecked string lengths, primitive-element bounds/lifetime contracts, and name-based delegate reinterpretation. Add regression coverage for invalid lengths and indices, supported lease/disposal behavior, and an unrelated delegate with a matching simple name. Do not deliberately dereference released storage in an in-process test.

  2. Remove unnecessary unsafe dependencies from tooling. Start with Files.XorLength, scanner UTF-8 encoding, and the legacy CRC implementation. Microsoft.Android.Build.BaseTasks targets netstandard2.0; select APIs supported by its references rather than assuming modern BCL availability. Preserve hash bytes, endianness, and the CRC variant, including offset-based inputs. Remove AllowUnsafeBlocks where a project genuinely no longer needs it.

  3. Migrate generators before broad opt-in. BoundMethod, BoundConstructor, and BoundProperty unconditionally emit member-level unsafety today. Under the new model, that becomes a downstream obligation. Keep ordinary bindings safe-callable where their implementations establish the required invariants; give raw pointer/handle APIs explicit contracts. Update both JNI generators, T4 templates, handwritten helpers, and expected-output fixtures, not only generated .cs files.

  4. Audit overrides and constructors separately. Do not mechanically retain today's unsafe modifiers: an unsafe override cannot add caller obligations to a safe base member. Candidates include primitive-array operations and Crc64.HashCore. Review constructor initializers, derived constructors, and generic construction constraints. Preserve safe public surfaces wherever the implementation owns the safety proof.

  5. Replace blanket unsafe scopes with member and field contracts. Audit RuntimeNativeMethods, JavaMarshalGCBridge, HandleContext, the JNI VM interface, and remapping structures. Use <safety> documentation for residual caller obligations and // SAFETY: notes for local proofs. Document sensitive field invariants, especially HandleContext.controlBlock and JniRemappingLookup.nativeData: provenance/allocator, initialized extent, ownership, synchronization, and lifetime. Mark contract-bearing fields where appropriate under the selected compiler rules.

  6. Classify native declarations and ABI-sensitive fields. Review every DllImport, LibraryImport, and function-pointer invocation against its native declaration before deciding whether obligations are discharged or propagated. Do not classify all native calls uniformly. Apply the selected compiler's explicit-layout field rules to JValue and JniArgumentValue, preserving exact JNI size, offsets, calling conventions, and representations across ABIs.

  7. Add actual new-model compilation coverage. generator-Tests/Integration-Tests/Compiler.cs:109-114 constructs Roslyn compilations directly with allowUnsafe: true; changing an MSBuild project property will not configure those compilations. Add explicit legacy/new-model test modes, compile generated output against real migrated reference assemblies, and test that unsafe APIs fail outside an unsafe context while safe wrappers remain callable. Verify contract metadata survives reference-assembly production and consumption.

  8. Roll out by dependency layer with compatibility gates. Confirm the supported opt-in mechanism for the chosen SDK, then migrate selected tooling, Java.Interop, Mono.Android, and downstream binding fixtures in dependency order. Account for linked source compiled into multiple projects. The shipped binding targets force AllowUnsafeBlocks=true in ExportJarToXml and AddBindingsToCompile; do not advertise binding applications as safe-only or silently enable the new model for all consumers. Test migrated/legacy caller-callee combinations, and recognize that legacy IntPtr APIs remain an annotation blind spot. Keep unsafe permission increases and model opt-outs visible in review.

  9. Audit paths outside C# compiler enforcement. TypeMapAssemblyEmitter emits PE/IL directly, including native registrations. Opting in its own project does not validate emitted IL. Review generated metadata and native-memory operations separately; test opted-in consumers of emitted assemblies, and add on-device coverage for activation, callbacks, registration, GC, and disposal under the affected runtimes/ABIs. Reflection and dynamic invocation also need separate review because the article identifies reflection as outside the initial enforcement scope.

Lifetime and validation notes

Replacing a pointer with a span does not solve lifetime problems when the span aliases releasable native storage. GC.KeepAlive prevents premature collection, not explicit disposal on another thread. Use ownership/ref-counted leases where needed; apply SafeHandle selectively rather than treating thread-affine JNI references as ordinary OS handles.

Validation for implementation work should include the standalone base-task and generator suites, legacy CRC compatibility tests, trimmable-typemap build tests, and the locally built SDK's on-device JNI/runtime tests. Add new-model positive/negative compilation tests in addition to existing runtime tests. None of those validations were executed for this source-only analysis.

Activity

  1. added this to the .NET 11 milestone on May 23, 2026
  2. changed the title [-][TrimmableTypeMap] Revisit use of `unsafe` in generated code and in libraries[/-] [+]Revisit use of `unsafe` in generated code and in libraries[/+] on Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions