Cysharp / Cysharp/ZString

Avoid aliasing decimal onto a private DecCalc layout via Unsafe.As

Open
#147 0 comments 0 reactions 1 assignee View on GitHub

@aetos382 is already working on this.

Since Sep 17, 2026.

Dominant language
C#
Stars
2.8k
Forks
192
PR merge metrics
No merged PRs in 30d

Description

Summary

src/ZString/Number/DecimalEx.cs accesses the internal representation of decimal by declaring an explicit-layout struct that mirrors the runtime's private DecCalc and aliasing a live decimal onto it with Unsafe.As. I would like to propose moving away from this technique.

This is the mildest case of a pattern I found across several Cysharp repositories, so I am filing it for consistency rather than because I think it is likely to break tomorrow.

Affected code

https://github.com/Cysharp/ZString/blob/master/src/ZString/Number/DecimalEx.cs

[StructLayout(LayoutKind.Explicit)]
private struct DecCalc
{
    // NOTE: Do not change the offsets of these fields. This structure must have the same layout as Decimal.
    [FieldOffset(0)] public uint uflags;
    [FieldOffset(4)] public uint uhi;
    [FieldOffset(8)] public uint ulo;
    [FieldOffset(12)] public uint umid;
    [FieldOffset(8)] private ulong ulomidLE;
    ...
}

static ref DecCalc AsMutable(ref decimal d) => ref Unsafe.As<decimal, DecCalc>(ref d);

DecDivMod1E9 then mutates uhi / umid / ulo in place, i.e. it writes to a live decimal through the aliased struct.

Why this is risky

  1. No contract. decimal's in-memory field order (flags, hi, lo, mid) is an implementation detail. It has been stable for a very long time and the runtime's own DecCalc uses exactly this layout, so the practical risk is low — but it is not a documented guarantee, and ulomidLE additionally assumes little-endian ordering.
  2. It fails silently. Unsafe.As performs no validation. If the layout ever differed, the result would not be an exception but wrong numeric output — or, since DecDivMod1E9 writes, a corrupted decimal.
  3. A public alternative exists. decimal.GetBits (all targets) and decimal.GetBits(decimal, Span<int>) / decimal.TryGetBits (.NET 5+) expose lo/mid/hi/flags as a documented contract, and new decimal(int lo, int mid, int hi, bool isNegative, byte scale) reconstructs the value. Building a local working struct from those and writing the result back through the constructor gives the same arithmetic without aliasing the original value's memory — the only cost is that the mutation becomes explicit rather than in-place.

Suggested direction

Replace AsMutable(ref decimal) with a conversion built on decimal.GetBits / the decimal constructor: keep the internal DecCalc-style struct for the division helpers, but populate it from the public accessors instead of reinterpreting the caller's decimal, and return the result as a new decimal. On .NET 5+ targets the span overload makes this allocation-free.

If that is judged not worth the churn given how stable decimal is, I would suggest at least dropping LayoutKind.Explicit aliasing in favour of a one-time layout assertion in a test, so a future change is caught by CI rather than by wrong output.

Note

I found this while auditing R3, and the same or similar pattern (assuming the private field layout of a BCL type and reinterpreting it with Unsafe.As) exists in several other Cysharp repositories (R3, ZLogger, ZLinq, ObservableCollections, MemoryPack). I am filing one issue per repository rather than a single cross-repo issue, since the right fix differs per project.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.