Skip to content

Commit 0663e17

Browse files
committed
feat(core,io-isolated-storage,runtime): the GetFolderPath table completed, 16-bit float unit 1, and three declarations (#2364, #2384, #2387, #2207, #2338)
#2364 is the only one that changed a value a caller gets, and it changed eight of them. #2320's recorded reason for stopping -- that the rest "has no .NET mapping, so it would cross from parity into invention" -- was measured FALSE: every remaining row maps onto ReadXdgDirectory or onto a static path, so these are alignments, not widenings. The sharpest row is Personal/MyDocuments, which returned the HOME DIRECTORY ITSELF, so an application writing "the user's documents" wrote into $HOME. UserProfile is asserted NOT to have moved with it. Two rows are the reverse shape: ProgramFiles and System were INVENTED here where .NET maps them only under TARGET_OSX, so the alignment REMOVES a mapping. The eight rows were never covered, which is why the divergence survived -- the full suite passed unchanged before and after. Every new case sets HOME and the XDG variables EXPLICITLY under DoNotVerify, so what is asserted is the mapping rather than what this machine happens to have. Six mutations, all caught. #2384 unit 1 gives both 16-bit floats nine members, and #2340's in-step rule worked as designed: adding them to BFloat16 alone BROKE THE BUILD. Four of the nine are bit operations, not float round-trips, and .NET says so itself. Six mutations, all caught -- but M6 only after a measurement corrected the test: the case used a NaN that was already quiet, and probing all 65,536 patterns showed the two forms differ on exactly 126, every one signalling. #2387, #2207 and #2338 change no production statement. #2387 leaves ExternalException::ToString() absent: unlike #2374's slot, a virtual here would only relocate the naming problem into cna's three derived types. #2207 declares the IsolatedStorage TOCTOU WITH ITS THREAT BOUNDARY -- it does stop a pre-existing symlink, and does not stop an attacker who can already write inside the root and therefore already reaches everything the race would win. #2338 keeps normalization invariant, and its premise was stale twice: /rv is present, and #2386 measured that this port matches .NET's invariant globalization mode rather than diverging from it. Gate: 17,574 run, 17,574 passed, 0 failed, 0 skipped across 38 executables (+13). Module graph 41/93. Build directory: build/ only, --parallel 2 throughout.
1 parent e20bba6 commit 0663e17

15 files changed

Lines changed: 1037 additions & 41 deletions

File tree

CLAUDE.md

Lines changed: 19 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Migration — POSIX `GetFolderPath` completes the .NET table (#2364)
3+
4+
Ticket **#2364**, landed 2026-08-19 on an explicit decision. **Decided against the recommendation
5+
on the record.**
6+
7+
## The premise #2320 recorded was false, and that is why this ticket exists
8+
9+
When #2320 offered full XDG and it was declined, the reason given was that *"part of it has no .NET
10+
mapping, so it would cross from parity into invention"*. Measured afterwards, that is **wrong**:
11+
every remaining row maps onto `ReadXdgDirectory` (`GetFolderPathCore.Unix.cs:220-249`) or onto a
12+
static path. #2320 stopped where it did for **scope**, not for invention — so the eight rows below
13+
are **alignments**, not widenings.
14+
15+
## What changed — eight rows, and some change a value callers get today
16+
17+
| Folder | before | after | reference |
18+
|---|---|---|---|
19+
| `Personal` / `MyDocuments` | `$HOME` | `ReadXdgDirectory(XDG_DOCUMENTS_DIR, "Documents")` | `:133-134` |
20+
| `Desktop` / `DesktopDirectory` | `$HOME/Desktop` | `ReadXdgDirectory(XDG_DESKTOP_DIR, "Desktop")` | `:119-121` |
21+
| `MyMusic` | `$HOME/Music` | `ReadXdgDirectory(XDG_MUSIC_DIR, "Music")` | `:135-136` |
22+
| `MyPictures` | `$HOME/Pictures` | `ReadXdgDirectory(XDG_PICTURES_DIR, "Pictures")` | `:139-140` |
23+
| `MyVideos` | `$HOME/Videos` | `ReadXdgDirectory(XDG_VIDEOS_DIR, "Videos")` | `:137-138` |
24+
| `Templates` | `$HOME/Templates` | `ReadXdgDirectory(XDG_TEMPLATES_DIR, "Templates")` | `:88-89` |
25+
| `CommonApplicationData` | `/etc` | **`/usr/share`** | `:54` |
26+
| `CommonTemplates` | *unmapped* | **`/usr/share/templates`** | `:55` |
27+
| `Fonts` | `/usr/share/fonts` | **`$HOME/.fonts`** | `:141-142` |
28+
| `ProgramFiles` | `/usr` | **`""`** | not mapped on Linux |
29+
| `System` | `/usr/lib` | **`""`** | not mapped on Linux |
30+
31+
**The sharpest row is `Personal`.** This port returned the **home directory itself**, so an
32+
application writing "the user's documents" wrote into `$HOME`. It now returns a `Documents`
33+
subdirectory. `UserProfile` still returns home — the two used to be the same answer, and a repair
34+
that moved both would look correct without the row that separates them.
35+
36+
**`CommonApplicationData` is the second.** `/etc` is not writable by a non-root user and is not
37+
where shared application data belongs.
38+
39+
**`ProgramFiles` and `System` are the reverse shape**, and worth stating separately: .NET maps them
40+
only under `TARGET_OSX` (`:57-60`), so on Linux no arm matches, `GetSpecialFolder` returns `null`,
41+
and `GetFolderPathCore` turns that into `""`. This port had **invented** a mapping where .NET has
42+
none, so here the alignment **removes** one.
43+
44+
## `ReadXdgDirectory`, transcribed
45+
46+
Three sources in .NET's order:
47+
48+
1. the environment variable, **if set and absolute** — a relative value is **ignored, not joined**,
49+
the same rule `xdgBase` already applied to the two base directories;
50+
2. the `XDG_DESKTOP_DIR="$HOME/Desktop"`-style lines of `user-dirs.dirs`, read out of the **XDG
51+
config directory**, so it honours `XDG_CONFIG_HOME` rather than a hard-coded `~/.config`;
52+
3. `home/fallback`.
53+
54+
The line grammar is transcribed rather than approximated, because each rejection is one .NET makes:
55+
whitespace is skipped before the key, around `=` and before the opening quote; a value must be
56+
either `$HOME/`-prefixed or absolute and **anything else is skipped rather than accepted**; the
57+
value ends at the next `"`, and an empty one is skipped. .NET eats every error reading the file, so
58+
an unreadable or malformed file falls through to the fallback.
59+
60+
## Tests — and the rule they follow
61+
62+
The eight rows **were never covered**, which is exactly why the divergence survived: the existing
63+
cases assert enum values and non-emptiness under `DoNotVerify`, and the full suite passed unchanged
64+
both before and after.
65+
66+
Every new case uses `DoNotVerify` and sets `HOME` and the XDG variables **explicitly**, so what is
67+
asserted is the *mapping* rather than what this machine happens to have. That is the #2320 / SA-6
68+
lesson as a rule: five of its rows passed only because this container has `~/Desktop`, which SA-6
69+
calls a defect in the **test** rather than evidence about the code.
70+
71+
`Decl2364_AnUnknownHomeStillFallsBackToRoot` pins the unchanged `"/"` fallback, because every new
72+
row builds on `home` and .NET states that fallback as a **safeguard**`"/"` is not writable by a
73+
non-root user, so a row that silently produced a relative `Documents` would be somewhere an
74+
application could write private data.
75+
76+
## Mutation testing
77+
78+
Six mutations, **all caught**:
79+
80+
| # | Mutation | Caught by |
81+
|---|---|---|
82+
| M1 | `Personal` reverts to home | the six-directory case |
83+
| M2 | a relative XDG variable is accepted | the absolute/relative case |
84+
| M3 | `user-dirs.dirs` read from a hard-coded `~/.config` | the file-grammar case |
85+
| M4 | a non-absolute `user-dirs.dirs` value is accepted | the file-grammar case |
86+
| M5 | the `ProgramFiles` mapping is restored | the static/unmapped case |
87+
| M6 | `CommonApplicationData` reverts to `/etc` | the static/unmapped case |
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Migration — the 16-bit floats gain .NET's math surface (#2384), unit 1
3+
4+
Ticket **#2384**, decided 2026-08-19: **add the surface**, per .NET. Its acceptance criterion
5+
requires it to land **as bounded units that move both types in step**, and this is unit 1.
6+
7+
**Purely additive.** No existing member changed, no layout moved (`sizeof(BFloat16)` is still 2),
8+
and nothing that compiled before fails now.
9+
10+
## Why both types, always
11+
12+
#2340 established that this surface must move for `System::Half` and
13+
`System::Numerics::BFloat16` **together or not at all**: adding it to one leaves the port's two
14+
16-bit floats inconsistent with each other, which is **worse than either policy applied
15+
consistently**.
16+
17+
That rule is enforced by the build, and it worked as designed: adding these members to `BFloat16`
18+
alone **broke the compile**, because #2382's pin asserted their absence with the message *"it must
19+
move System::Half too"*. The pin is now partially inverted — it asserts presence on **both** types,
20+
and still asserts unit 2's absence on both, so the next unit gets the same signal this one did.
21+
22+
## Unit 1 — nine members, and four of them are not what you would guess
23+
24+
| member | .NET | shape |
25+
|---|---|---|
26+
| `Abs` | `Half.cs:1756`, `BFloat16.cs:1396` | **bit mask** `value & ~SignMask` |
27+
| `CopySign` | `Half.cs:1654-1664`, `BFloat16.cs:1300-1310` | **bitwise** |
28+
| `BitIncrement` | `Half.cs:1478-1508`, `BFloat16.cs:1134-1164` | **bit arithmetic** |
29+
| `BitDecrement` | `Half.cs:1445-1475`, `BFloat16.cs:1101-1131` | **bit arithmetic** |
30+
| `Clamp` | `Half.cs:1641`, `BFloat16.cs:1297` | float round-trip |
31+
| `Max`, `Min` | `Half.cs:1667…` | float round-trip |
32+
| `MaxMagnitude`, `MinMagnitude` | `Half.cs:1851,1879`, `BFloat16.cs:1491,1519` | float round-trip |
33+
34+
**A blanket "forward to `float`" would be wrong for the first four**, and .NET says so itself:
35+
`CopySign` carries the comment *"This method is required to work for all inputs, including NaN, so
36+
we operate on the raw bits."*
37+
38+
**Three edges of `BitIncrement`/`BitDecrement` are not arithmetic** and are transcribed rather than
39+
derived: a NaN returns itself, `-Infinity` **increments to `MinValue`** and `+Infinity`
40+
**decrements to `MaxValue`**, and — the one most easily lost — **`-0.0` increments to `Epsilon`**
41+
while **`+0.0` decrements to `-Epsilon`**.
42+
43+
## Every body was derived per type, not copied
44+
45+
This is #2382's lesson applied rather than quoted. There, copying `Half::GetHashCode` into
46+
`BFloat16` would have **compiled, satisfied the hash contract and returned the wrong number**,
47+
because .NET's `BFloat16` delegates its identity trio to `float` while `Half` masks to 16 bits.
48+
49+
For these nine the two references **do** agree — and that was *checked*, not assumed. The only
50+
textual difference is that `BFloat16.Clamp` calls `Math.Clamp` where `Half.Clamp` calls
51+
`float.Clamp`; same semantics.
52+
53+
## Mutation testing
54+
55+
Six mutations, all caught — **but M6 only after a measurement corrected the test**:
56+
57+
| # | Mutation | Caught by |
58+
|---|---|---|
59+
| M1 | `Half::Abs` via a float round-trip | the bit-operations case |
60+
| M2 | `Half::CopySign` via `std::copysign` | the same |
61+
| M3 | `BitIncrement(-0.0)` returns `+0.0` | the edges case |
62+
| M4 | `BitIncrement` drops the negative branch | the same |
63+
| M5 | `MaxMagnitude` is really `Max` | the round-trip case |
64+
| M6 | `BFloat16::Abs` via a float round-trip | the bit-operations case, **after repair** |
65+
66+
**M6 was first NOT CAUGHT while its `Half` twin M1 was**, and the reason was in the test, not the
67+
code: the case used `0x7FC1`, a NaN that is **already quiet**, so a round-trip returned it
68+
unchanged. Probed exhaustively over all 65,536 patterns, the two forms differ on **exactly 126**
69+
every one a **signalling** NaN, which `fromFloat` quiets by OR-ing in `0x0040`. The case now uses
70+
`0x7F81` and asserts the exact bits.
71+
72+
## What is still to come
73+
74+
Unit 1 is the sign/magnitude family. **Unit 2** is the transcendental families (`Sqrt`, `Exp`,
75+
`Log`, the trigonometric and hyperbolic sets, `Pow`, `FusedMultiplyAdd`, `ReciprocalEstimate`) and
76+
**unit 3** is the conversion operators. Both remain, both must move the two types in step, and
77+
unit 2's absence is pinned on both types today.
78+
79+
Out of scope permanently, and unchanged by this ticket: **generic-math conformance**
80+
(`INumber<T>`, `IFloatingPointIeee754<T>`, `IMinMaxValue<T>`). .NET's `BFloat16` implements 36
81+
interfaces and none of them is expressible here.

modules/core/include/System/Half.hpp

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
55
#include <cstdint>
6+
#include "System/MathF.hpp"
67
#include <cstring>
78
#include <string>
89
#include <cmath>
@@ -184,6 +185,103 @@ namespace System {
184185
static const Half PositiveInfinity;
185186
/** @brief Represents negative infinity. C++ counterpart of .NET Half.NegativeInfinity. */
186187
static const Half NegativeInfinity;
188+
// -----------------------------------------------------------------------------------
189+
// #2384 unit 1: the sign/magnitude family. Every body is DERIVED PER TYPE from the
190+
// reference rather than copied across, because #2382 measured what copying costs -- its
191+
// GetHashCode would have compiled, satisfied the hash contract and returned the wrong
192+
// number. Here the two types agree, and that was CHECKED rather than assumed.
193+
//
194+
// Note which of these are bit operations and which are float round-trips: .NET makes that
195+
// distinction deliberately, and a blanket "forward to float" would be wrong for four of
196+
// the nine.
197+
// -----------------------------------------------------------------------------------
198+
199+
/**
200+
* @brief Returns the absolute value.
201+
*
202+
* C++ counterpart of .NET `Half.Abs(Half)` (`Half.cs:1756`), which is a **bit mask**,
203+
* `value._value & ~SignMask` -- NOT a float round-trip. That matters for NaN: masking
204+
* preserves the payload and the quiet bit, where a round-trip through `float` would
205+
* canonicalise them (see this type's own note on NaN payloads).
206+
*/
207+
[[nodiscard]] static Half Abs(Half value) noexcept {
208+
return Half(static_cast<uint16_t>(value.bits & 0x7FFFu));
209+
}
210+
211+
/**
212+
* @brief Copies the sign of @p sign onto the magnitude of @p value.
213+
*
214+
* C++ counterpart of .NET `Half.CopySign(Half, Half)` (`Half.cs:1654-1664`). .NET states
215+
* why it is bitwise in a comment of its own -- *"This method is required to work for all
216+
* inputs, including NaN, so we operate on the raw bits"* -- so this is transcribed rather
217+
* than expressed through `std::copysign` on a converted `float`.
218+
*/
219+
[[nodiscard]] static Half CopySign(Half value, Half sign) noexcept {
220+
return Half(static_cast<uint16_t>((value.bits & 0x7FFFu) | (sign.bits & 0x8000u)));
221+
}
222+
223+
/**
224+
* @brief Returns the next representable value greater than @p x.
225+
*
226+
* C++ counterpart of .NET `Half.BitIncrement(Half)` (`Half.cs:1478-1508`), transcribed
227+
* including its three non-obvious edges: a NaN returns itself, `-Infinity` returns
228+
* `MinValue` (not `-MaxValue` by arithmetic), `+Infinity` returns itself, and **-0.0
229+
* returns `Epsilon`** rather than +0.0.
230+
*/
231+
[[nodiscard]] static Half BitIncrement(Half x) noexcept {
232+
uint16_t bits = x.bits;
233+
if (!IsFinite(x)) {
234+
// NaN -> NaN, -Infinity -> MinValue, +Infinity -> +Infinity
235+
return (bits == 0xFC00u) ? MinValue : x;
236+
}
237+
if (bits == 0x8000u) return Epsilon; // -0.0 -> Epsilon
238+
// Negative values are decremented, positive values incremented.
239+
if (IsNegative(x)) --bits; else ++bits;
240+
return Half(bits);
241+
}
242+
243+
/**
244+
* @brief Returns the next representable value less than @p x.
245+
*
246+
* C++ counterpart of .NET `Half.BitDecrement(Half)` (`Half.cs:1445-1475`). The mirror of
247+
* BitIncrement, and its edges mirror too: `+Infinity` returns `MaxValue` and **+0.0
248+
* returns `-Epsilon`**.
249+
*/
250+
[[nodiscard]] static Half BitDecrement(Half x) noexcept {
251+
uint16_t bits = x.bits;
252+
if (!IsFinite(x)) {
253+
// NaN -> NaN, +Infinity -> MaxValue, -Infinity -> -Infinity
254+
return (bits == 0x7C00u) ? MaxValue : x;
255+
}
256+
if (bits == 0x0000u) {
257+
// +0.0 -> -Epsilon
258+
return Half(static_cast<uint16_t>(Epsilon.bits | 0x8000u));
259+
}
260+
if (IsNegative(x)) ++bits; else --bits;
261+
return Half(bits);
262+
}
263+
264+
/**
265+
* @brief Clamps @p value to the inclusive range [@p min, @p max].
266+
*
267+
* C++ counterpart of .NET `Half.Clamp(Half, Half, Half)` (`Half.cs:1641`), which **is** a
268+
* float round-trip -- `(Half)float.Clamp((float)value, (float)min, (float)max)`.
269+
* @throws System::ArgumentException if @p min is greater than @p max, matching
270+
* `Math.Clamp`'s own contract.
271+
*/
272+
[[nodiscard]] static Half Clamp(Half value, Half min, Half max);
273+
274+
/** @brief The larger of two values. .NET: `(Half)float.Max(...)` (`Half.cs:1667`). */
275+
[[nodiscard]] static Half Max(Half x, Half y) noexcept;
276+
/** @brief The smaller of two values. .NET's counterpart of Max. */
277+
[[nodiscard]] static Half Min(Half x, Half y) noexcept;
278+
/** @brief The value with the larger magnitude. .NET: `(Half)MathF.MaxMagnitude(...)`
279+
* (`Half.cs:1851`). */
280+
[[nodiscard]] static Half MaxMagnitude(Half x, Half y) noexcept;
281+
/** @brief The value with the smaller magnitude. .NET: `(Half)MathF.MinMagnitude(...)`
282+
* (`Half.cs:1879`). */
283+
[[nodiscard]] static Half MinMagnitude(Half x, Half y) noexcept;
284+
187285
/** @brief Represents the largest finite half-precision value (65504). C++ counterpart of .NET Half.MaxValue. */
188286
static const Half MaxValue;
189287
/** @brief Represents the most negative finite half-precision value (-65504). C++ counterpart of .NET Half.MinValue. */
@@ -427,6 +525,25 @@ namespace System {
427525
inline const Half Half::NaN = Half(0xFE00);
428526
inline const Half Half::PositiveInfinity = Half(0x7C00);
429527
inline const Half Half::NegativeInfinity = Half(0xFC00);
528+
// #2384 unit 1: the four float round-trip members, defined after the constants they need.
529+
// Each is .NET's own expression, not a re-derivation -- Clamp is float.Clamp, Max/Min are
530+
// float.Max/float.Min, and MaxMagnitude/MinMagnitude are MathF.MaxMagnitude/MinMagnitude.
531+
inline Half Half::Clamp(Half value, Half min, Half max) {
532+
return FromSingle(System::MathF::Clamp(value.ToSingle(), min.ToSingle(), max.ToSingle()));
533+
}
534+
inline Half Half::Max(Half x, Half y) noexcept {
535+
return FromSingle(System::MathF::Max(x.ToSingle(), y.ToSingle()));
536+
}
537+
inline Half Half::Min(Half x, Half y) noexcept {
538+
return FromSingle(System::MathF::Min(x.ToSingle(), y.ToSingle()));
539+
}
540+
inline Half Half::MaxMagnitude(Half x, Half y) noexcept {
541+
return FromSingle(System::MathF::MaxMagnitude(x.ToSingle(), y.ToSingle()));
542+
}
543+
inline Half Half::MinMagnitude(Half x, Half y) noexcept {
544+
return FromSingle(System::MathF::MinMagnitude(x.ToSingle(), y.ToSingle()));
545+
}
546+
430547
inline const Half Half::MaxValue = Half(0x7BFF); // 65504
431548
inline const Half Half::MinValue = Half(0xFBFF); // -65504
432549
inline const Half Half::Epsilon = Half(0x0001); // ~5.96e-8

0 commit comments

Comments
 (0)