CNA-BUG-168: LocalGamerServicesStore maps distinct gamertags to one achievements file when they differ only in characters outside A-Z, a-z, 0-9, '.', '_' and '-'
Evidence basis: source-verified at the pinned commit; executed for this entry (the Evidence section names exactly what was run); tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.
SanitizeStoreFileNameComponent replaces every other byte with an underscore, so gamertags such as "Ann B" and "Ann_B", or two same-length non-ASCII names, share one achievements file and see each other's achievements.
- Identifier
CNA-BUG-168- Category
- Bug
- Subsystem
- Networking & gamer services
- Status
- Open
- Verified against
- CNA
c1c316b9(c1c316b9c7a846ce8002809c151fcd1af14942c9) - Severity
- Low (a triage suggestion, not a project priority)
- Evidence basis
- Reproduced: executed for this entry (the Evidence section names exactly what was run)
- Tests touching this area
- Yes: see Current tests
- Affected contract
- SignedInGamer::AwardAchievement and GetAchievements persistence (CNA::Internal::GamerServices::SaveEarnedAchievementEXT, LoadEarnedAchievementsEXT, SanitizeStoreFileNameComponent)
Expected behaviour
Achievements belong to one gamer: GetAchievements returns what that gamertag earned and nothing another gamer earned.
Actual behaviour at TARGET
The achievements file is achievements/<SanitizeStoreFileNameComponent(gamertag)>.json (LocalGamerServicesStore.cpp). The sanitiser keeps ASCII letters, digits, -, _ and ., replaces every other byte with _ (each byte of a multi-byte UTF-8 character separately) and maps an empty name to _. The mapping is many-to-one and the file does not record the original gamertag, so "Ann B", "Ann_B" and "Ann!B" read and write one list, as do two non-ASCII names whose characters have the same byte lengths. Leaderboards are not affected in the same way: each board file stores every entry's raw gamertag.
Source locations
modules/gamer-services/src/Internal/LocalGamerServicesStore.cpp— SanitizeStoreFileNameComponent; LoadEarnedAchievementsEXT and SaveEarnedAchievementEXT name the file by the sanitised gamertagmodules/gamer-services/src/Xna/SignedInGamer.cpp— AwardAchievement and EndGetAchievements key the store by gamertagmodules/gamer-services/src/Internal/LocalProfiles.cpp— isValidLocalGamertag: the Guide's local profiles cannot collide
Evidence
Checked by reading at c1c316b9; the audit's own executed probe follows below. The Guide's local profiles cannot collide (their names pass isValidLocalGamertag), so only application-created gamers can. The gamer-services deep dive states that the mapping "does not make identities unique" but not the consequence.
Independent re-verification: Reproduced by the audit at 009d40f5. A probe built from that commit's LocalGamerServicesStore.cpp, PropertyDictionary.cpp and the storage sources (g++ -O0; the Sharp Runtime types came from a sibling checkout that TARGET does not pin) sanitised “Ann B”, “Ann_B” and “Ann!B” to the same name Ann_B, and the two non-ASCII names “éx” and “šx” to the same __x. After SaveEarnedAchievementEXT("Ann B", "first_steps", ...) one file, achievements/Ann_B.json, existed, and LoadEarnedAchievementsEXT for “Ann_B” and for “Ann!B” each returned that entry, while “Bob” returned none.
Re-verified at b0e97bb1: SanitizeStoreFileNameComponent is unchanged; since ac961f433 (GS-004l) the Guide's local profiles must pass isValidLocalGamertag (ASCII letters, digits and single spaces, matched case-insensitively), and the dispatcher no longer creates gamers of its own (0ab6cc3fe). Not executed.
Focused reproduction
// Illustrative; the audit's executed probe is described under Evidence.
auto ann = SignedInGamer::CreateInternal("Ann B");
auto ann2 = SignedInGamer::CreateInternal("Ann_B");
ann.AwardAchievement("first_steps"); // writes achievements/Ann_B.json
auto list = ann2.GetAchievements(); // contains first_steps
Current tests
SignedInGamerTest.AchievementsAreIsolatedPerGamertag in GamerServicesGamerTests.cpp uses gamertags that sanitise to different names; no test uses two that sanitise to the same one.
Regression test
Two gamertags that differ only in a space, punctuation or a non-ASCII character, each awarded a different key, each reading back only its own; a collision-free encoding (percent-encoding or a hash suffix, with the gamertag stored in the file) satisfies it.
Blast radius
Gamers created with arbitrary gamertags through SignedInGamer::CreateInternal or the C route cna_signed_in_gamer_create_ext while no service is configured: two names that sanitise alike share one achievements list. Local profiles from the Guide's sign-in are limited to ASCII letters, digits and single spaces and matched case-insensitively (LocalProfiles.cpp, isValidLocalGamertag), so two of them cannot collide, and service accounts keep their achievements on the service. Leaderboards are unaffected.
Workaround
Restrict profile names to ASCII letters, digits, -, _ and ., or keep names distinct after that mapping.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Deep dives
- GamerServices contract: persistence
- Known issues
- Bug index