Skip to content

Commit 3892088

Browse files
committed
fix(core): ApplicationId takes .NET's shape (#2291, SR-AUD-117; closes #2292)
All four of the review's decisions, taken toward .NET and together -- the review said (4) 'cannot be decided first' because it depends on (2) and (3), which is why they land in one change. (1) An empty name is rejected, through the port's existing helper, so no message is invented. (2) THE TOKEN IS BYTES AND IS CLONED AT BOTH ENDS. .NET clones on the way in (ApplicationId.cs:19) and on the way out (:34) -- a defensive copy on EVERY access -- so the getter returns BY VALUE; a const& would have handed the caller the stored array and defeated the constructor's own copy. A test mutates the caller's array AND the returned one. A std::string also could not carry binary key material, which a token containing an embedded NUL demonstrates. (3) Culture and ProcessorArchitecture are std::optional, matching string?. Absent and empty were one state. (4) ToString() is .NET's grammar, with TWO REFERENCE QUIRKS TRANSCRIBED RATHER THAN TIDIED, because a caller may match on them: processorArchitecture carries a space before its '=' where the other three keys do not, and the token is emitted even when EMPTY because .NET's guard is a null test on the array. The old text omitted the token entirely, so two identities differing only by token produced IDENTICAL strings. #2292 IS CLOSED ON THE WAY PAST: GetHashCode was noexcept while hashing version_.ToString(), which allocates, so an allocation failure called std::terminate rather than propagating. It now composes Version::GetHashCode(), which is both what .NET does and allocation-free. The hash still deliberately ignores the token, and a test asserts that together with Equals comparing it, so neither can be 'fixed' in isolation. Fixture set: 28 fixtures / 170 sites. Gate 17,279 run, 0 failed. Downstream: zero sites in either consumer.
1 parent c6dc386 commit 3892088

8 files changed

Lines changed: 428 additions & 94 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

audit/AUDIT_FINDINGS_INDEX.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `ApplicationId` takes .NET's shape (ticket #2291)
5+
6+
*2026-08-18.* All four of the review's decisions, taken toward .NET.
7+
8+
Landed under `docs/StandingApprovals.md` **SA-8** and **SA-9**, with **SA-10** for the signature
9+
changes, under SA-2's five conditions.
10+
11+
---
12+
13+
## 1. The four changes
14+
15+
| # | | Was | Is |
16+
|---|---|---|---|
17+
| 1 | **name validation** | `""` accepted silently | `ArgumentException` |
18+
| 2 | **public key token** | `std::string`, stored verbatim, no clone | `std::vector<bytecs>`, **cloned in and out** |
19+
| 3 | **Culture, ProcessorArchitecture** | `std::string` — absent = empty | `std::optional<std::string>` |
20+
| 4 | **`ToString()`** | this port's own grammar, **token omitted** | .NET's grammar |
21+
| | `GetHashCode()` | `noexcept`, hashed `Version::ToString()` | composes `Version::GetHashCode()`, not `noexcept` |
22+
23+
The review said (4) *"cannot be decided first"* because it depends on (2) and (3). It was decided
24+
last, and all four landed together for that reason.
25+
26+
## 2. The token is bytes, and cloned at both ends
27+
28+
.NET's is `byte[]`, `(byte[])publicKeyToken.Clone()` on the way in (`ApplicationId.cs:19`) and
29+
`=> (byte[])_publicKeyToken.Clone()` on the way **out** (`:34`) — a defensive copy on *every*
30+
access. A `const&` return would have handed the caller the stored array and defeated the
31+
constructor's own copy, so `getPublicKeyTokenProperty()` returns **by value**. A test mutates both
32+
the caller's array and the returned one and asserts the stored token is untouched.
33+
34+
A `std::string` also could not carry binary key material: a test uses a token containing an
35+
embedded NUL, which the old representation could not represent at all.
36+
37+
## 3. `ToString()` — two reference quirks transcribed rather than tidied
38+
39+
```
40+
<name>[, culture="<c>"], version="<v>", publicKeyToken="<HEX>"[, processorArchitecture ="<a>"]
41+
```
42+
43+
* `processorArchitecture` carries a **space before its `=`** where the other three keys do not.
44+
That is `ApplicationId.cs:63`, and it is reproduced deliberately because a caller may match on
45+
it.
46+
* The token is emitted **even when empty**, because .NET's guard is a null test on the array and a
47+
zero-length array is not null.
48+
49+
The old text omitted the token entirely, so **two identities differing only by public key token
50+
produced identical strings** — the reason this half of the finding existed.
51+
52+
## 4. `GetHashCode`#2292 closed on the way past
53+
54+
It was `noexcept` while hashing `version_.ToString()`, which allocates, so an allocation failure
55+
called `std::terminate` rather than propagating. It now composes `Version::GetHashCode()`, which
56+
is both what .NET does (`Name.GetHashCode() ^ Version.GetHashCode()`) and allocation-free.
57+
58+
The `noexcept` is dropped anyway: a hash that composes another type's user-defined hash should not
59+
promise more than that hash does.
60+
61+
**The hash still deliberately ignores the token, culture and architecture**, and .NET says why in
62+
its own comment — *"purposely skipping … as they are less likely to make things not equal than
63+
name and version"*. So two identities differing only by token are **unequal** and **hash the
64+
same**, which is permitted; a test asserts both halves together so neither can be "fixed" in
65+
isolation.
66+
67+
## 5. To migrate
68+
69+
```cpp
70+
// before
71+
ApplicationId id("token123", "MyApp", ver, "amd64", "neutral");
72+
const std::string& c = id.getCultureProperty();
73+
if (id.getPublicKeyTokenProperty() == "token123") { ... }
74+
75+
// after
76+
const std::vector<SharpRuntime::bytecs> token{0xDE, 0xAD, 0xBE, 0xEF};
77+
ApplicationId id(token, "MyApp", ver, "amd64", "neutral"); // literals still convert
78+
const std::string c = id.getCultureProperty().value_or("");
79+
if (id.getPublicKeyTokenProperty() == token) { ... }
80+
```
81+
82+
A string literal still converts implicitly into the optional parameters, so a **construction** site
83+
that supplies both components needs no edit beyond the token.
84+
85+
## 6. Downstream, measured
86+
87+
Neither `cna` nor `mobile-eggbert` mentions `ApplicationId` — **zero sites in both**. Neither
88+
repository was modified.

modules/core/include/System/ApplicationId.hpp

Lines changed: 126 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
55
#include <string>
6+
#include <optional>
7+
#include <vector>
8+
#include <utility>
9+
#include "System/ArgumentException.hpp"
610
#include "System/Version.hpp"
711

812
namespace System {
@@ -37,48 +41,82 @@ namespace System {
3741
* comment.
3842
*/
3943
class ApplicationId {
40-
std::string name_;
41-
Version version_;
42-
std::string processorArchitecture_;
43-
std::string culture_;
44-
std::string publicKeyToken_;
44+
std::string name_;
45+
Version version_;
46+
std::optional<std::string> processorArchitecture_;
47+
std::optional<std::string> culture_;
48+
std::vector<SharpRuntime::bytecs> publicKeyToken_;
4549

4650
public:
4751
/**
48-
* @brief Initializes a new instance with the specified identity components.
52+
* @brief Constructs an ApplicationId.
4953
*
50-
* C++ counterpart of .NET ApplicationId(byte[], string, Version, string, string).
51-
* @param publicKeyToken The application's public key token (as a string).
52-
* @param name The application name.
53-
* @param version The application version.
54-
* @param processorArchitecture The processor architecture ("x86", "amd64", etc.).
55-
* @param culture The culture string ("neutral", "en-US", etc.).
54+
* C++ counterpart of .NET `ApplicationId(byte[], string, Version, string?, string?)`
55+
* (`ApplicationId.cs:13-24`).
56+
*
57+
* @par Ticket #2291 took all four of the review's decisions, toward .NET
58+
* 1. **The name is validated.** .NET raises for a null or empty name; this port accepted
59+
* `""` silently. `ArgumentException::ThrowIfNullOrEmpty` is the port's existing
60+
* helper, so no message is invented.
61+
* 2. **The token is bytes, not text.** It was a `std::string` stored verbatim, so binary
62+
* key material was unrepresentable and no clone was made. .NET takes `byte[]` and
63+
* **clones on the way in and on the way out**, so a caller cannot mutate a stored
64+
* token through the array it passed or the one it received.
65+
* 3. **Culture and ProcessorArchitecture are optional.** They are `string?` in .NET, and
66+
* were non-nullable `std::string` here, so absent and empty were one state.
67+
* 4. **`ToString()` adopts .NET's grammar** — see its own doc-comment.
68+
*
69+
* @throws System::ArgumentException if @p name is empty.
5670
*/
57-
ApplicationId(const std::string& publicKeyToken,
71+
ApplicationId(std::vector<SharpRuntime::bytecs> publicKeyToken,
5872
const std::string& name,
5973
const Version& version,
60-
const std::string& processorArchitecture,
61-
const std::string& culture)
74+
std::optional<std::string> processorArchitecture,
75+
std::optional<std::string> culture)
6276
: name_(name), version_(version),
63-
processorArchitecture_(processorArchitecture),
64-
culture_(culture), publicKeyToken_(publicKeyToken) {}
77+
processorArchitecture_(std::move(processorArchitecture)),
78+
culture_(std::move(culture)),
79+
publicKeyToken_(std::move(publicKeyToken)) {
80+
System::ArgumentException::ThrowIfNullOrEmpty(name, "name");
81+
}
6582

6683
/** @brief Gets the application name. C++ counterpart of .NET ApplicationId.Name. */
6784
[[nodiscard]] const std::string& getNameProperty() const { return name_; }
6885

6986
/** @brief Gets the application version. C++ counterpart of .NET ApplicationId.Version. */
7087
[[nodiscard]] const Version& getVersionProperty() const { return version_; }
7188

72-
/** @brief Gets the processor architecture. C++ counterpart of .NET ApplicationId.ProcessorArchitecture. */
73-
[[nodiscard]] const std::string& getProcessorArchitectureProperty() const {
89+
/**
90+
* @brief Gets the processor architecture, or `std::nullopt` if absent.
91+
*
92+
* C++ counterpart of .NET `ApplicationId.ProcessorArchitecture`, which is `string?`.
93+
* Nullable since ticket #2291.
94+
*/
95+
[[nodiscard]] const std::optional<std::string>& getProcessorArchitectureProperty() const {
7496
return processorArchitecture_;
7597
}
7698

77-
/** @brief Gets the application culture. C++ counterpart of .NET ApplicationId.Culture. */
78-
[[nodiscard]] const std::string& getCultureProperty() const { return culture_; }
99+
/**
100+
* @brief Gets the application culture, or `std::nullopt` if absent.
101+
*
102+
* C++ counterpart of .NET `ApplicationId.Culture`, which is `string?`.
103+
* Nullable since ticket #2291.
104+
*/
105+
[[nodiscard]] const std::optional<std::string>& getCultureProperty() const {
106+
return culture_;
107+
}
79108

80-
/** @brief Gets the public key token. C++ counterpart of .NET ApplicationId.PublicKeyToken. */
81-
[[nodiscard]] const std::string& getPublicKeyTokenProperty() const { return publicKeyToken_; }
109+
/**
110+
* @brief Gets a COPY of the public key token.
111+
*
112+
* C++ counterpart of .NET `ApplicationId.PublicKeyToken`, which is
113+
* `=> (byte[])_publicKeyToken.Clone()` (`ApplicationId.cs:34`) — a defensive copy on
114+
* **every access**. Returning by value is that clone; a `const&` would have handed the
115+
* caller the stored array and defeated the constructor's own copy.
116+
*/
117+
[[nodiscard]] std::vector<SharpRuntime::bytecs> getPublicKeyTokenProperty() const {
118+
return publicKeyToken_;
119+
}
82120

83121
/**
84122
* @brief Creates a copy of this ApplicationId.
@@ -90,64 +128,91 @@ namespace System {
90128
/**
91129
* @brief Determines whether this instance and the specified object have the same value.
92130
*
93-
* C++ counterpart of .NET ApplicationId.Equals(object).
94-
* Two ApplicationId instances are equal when all five fields match.
131+
* C++ counterpart of .NET `ApplicationId.Equals` (`ApplicationId.cs:71-77`), which
132+
* compares all five components and the token **element by element**.
95133
*/
96134
[[nodiscard]] bool Equals(const ApplicationId& other) const {
97-
return name_ == other.name_
98-
&& version_ == other.version_
135+
return name_ == other.name_
136+
&& version_ == other.version_
99137
&& processorArchitecture_ == other.processorArchitecture_
100-
&& culture_ == other.culture_
101-
&& publicKeyToken_ == other.publicKeyToken_;
138+
&& culture_ == other.culture_
139+
&& publicKeyToken_ == other.publicKeyToken_;
102140
}
103141

104142
bool operator==(const ApplicationId& o) const { return Equals(o); }
105143
bool operator!=(const ApplicationId& o) const { return !Equals(o); }
106144

107145
/**
108-
* @brief Returns a hash code for this ApplicationId based on the name and version.
146+
* @brief Returns a hash code derived from the name and version only.
109147
*
110-
* C++ counterpart of .NET ApplicationId.GetHashCode(), which also derives
111-
* the code from those two components only. Equal instances therefore hash
112-
* equally even though `Equals` compares all five fields; unequal ones may
113-
* collide, which is permitted.
148+
* C++ counterpart of .NET `ApplicationId.GetHashCode()`
149+
* (`ApplicationId.cs:79-82`), which carries its own comment: *"purposely skipping
150+
* publicKeyToken, processor architecture and culture as they are less likely to make
151+
* things not equal than name and version."* Equal instances therefore hash equally even
152+
* though `Equals` compares all five; unequal ones may collide, which is permitted.
114153
*
115-
* @warning This is declared `noexcept` while `Version::ToString()` builds
116-
* a `std::string`, so an allocation failure here calls `std::terminate`
117-
* rather than propagating. Ticket #2292; no `SR-AUD-*` identifier.
154+
* @note It is **no longer `noexcept`**, and that closes ticket #2292: it used to hash
155+
* `version_.ToString()`, which allocates, so an allocation failure called
156+
* `std::terminate` rather than propagating. It now composes `Version::GetHashCode()`
157+
* instead, which is both what .NET does and allocation-free — so the `noexcept`
158+
* could arguably have stayed, and is dropped anyway because a hash that composes
159+
* another type's virtual-free but user-defined hash should not promise more than
160+
* that hash does.
118161
*/
119-
[[nodiscard]] int GetHashCode() const noexcept {
120-
std::size_t h = std::hash<std::string>{}(name_);
121-
h ^= std::hash<std::string>{}(version_.ToString()) + 0x9e3779b9 + (h << 6) + (h >> 2);
122-
return static_cast<int>(h);
162+
[[nodiscard]] SharpRuntime::intcs GetHashCode() const {
163+
return static_cast<SharpRuntime::intcs>(
164+
static_cast<SharpRuntime::intcs>(std::hash<std::string>{}(name_)) ^
165+
version_.GetHashCode());
123166
}
124167

125168
/**
126-
* @brief Returns a string representation of the application identity,
127-
* in this port's own grammar.
169+
* @brief Returns .NET's textual representation of the application identity.
170+
*
171+
* C++ counterpart of .NET `ApplicationId.ToString()` (`ApplicationId.cs:38-69`),
172+
* transcribed since ticket #2291. The grammar is:
173+
*
174+
* <name>[, culture="<c>"], version="<v>"[, publicKeyToken="<HEX>"][, processorArchitecture ="<a>"]
128175
*
129-
* C++ counterpart of .NET ApplicationId.ToString() **in role only** — the
130-
* text is not .NET's. This emits
131-
* `<name>, Version=<v>, Culture=<c>, ProcessorArchitecture=<a>`:
132-
* capitalized unquoted keys, both optional components always present,
133-
* and **the public key token never included**. .NET writes lowercase
134-
* quoted `culture`, `version`, `publicKeyToken` (uppercase hex bytes) and
135-
* `processorArchitecture`, omitting components that are null.
176+
* lowercase quoted keys, absent components omitted, and the token as **uppercase** hex.
136177
*
137-
* The consequence is worth stating plainly: **two ApplicationIds that
138-
* differ only by public key token produce identical text here**, so this
139-
* string does not identify an ApplicationId and must not be used as a
140-
* manifest identity or as an equality proxy. `Equals` compares all five
141-
* fields and does distinguish them.
178+
* @note Two details are transcribed rather than tidied, because they are the reference's
179+
* and a caller may match on them. The token is emitted even when EMPTY -- .NET's
180+
* guard is `_publicKeyToken != null`, and a zero-length array is not null, so an
181+
* empty token yields `publicKeyToken=""`. And `processorArchitecture` carries a
182+
* **space before its `=`**, which the other three keys do not; that asymmetry is in
183+
* `ApplicationId.cs:63` and is reproduced deliberately.
142184
*
143-
* Changing this text would break any consumer that parses or logs it, and
144-
* matching .NET needs the byte-token representation SR-AUD-124 is about,
145-
* so both are held under one decision — SR-AUD-125, ticket #2291.
185+
* Before #2291 this emitted a different grammar of its own -- capitalized unquoted keys,
186+
* both optional components always present, and **the token never included**, so two
187+
* identities differing only by token produced identical text.
146188
*/
147189
[[nodiscard]] std::string ToString() const {
148-
return name_ + ", Version=" + version_.ToString()
149-
+ ", Culture=" + culture_
150-
+ ", ProcessorArchitecture=" + processorArchitecture_;
190+
static constexpr char kHexDigits[] = "0123456789ABCDEF";
191+
std::string out = name_;
192+
if (culture_.has_value()) {
193+
out += ", culture=\"";
194+
out += *culture_;
195+
out += '"';
196+
}
197+
out += ", version=\"";
198+
out += version_.ToString();
199+
out += '"';
200+
// Always emitted: .NET's guard is a null test on the array, and this port's
201+
// std::vector is never null -- so an empty token prints as an empty quoted value,
202+
// which is what .NET does for `new byte[0]` too.
203+
out += ", publicKeyToken=\"";
204+
for (SharpRuntime::bytecs b : publicKeyToken_) {
205+
const auto v = static_cast<unsigned char>(b);
206+
out += kHexDigits[(v >> 4) & 0x0F];
207+
out += kHexDigits[v & 0x0F];
208+
}
209+
out += '"';
210+
if (processorArchitecture_.has_value()) {
211+
out += ", processorArchitecture =\""; // the reference's own space before '='
212+
out += *processorArchitecture_;
213+
out += '"';
214+
}
215+
return out;
151216
}
152217
};
153218

0 commit comments

Comments
 (0)