CNA-BUG-151: StorageDevice::EnsureStorageRoot latches an empty root after a failed resolution, and storage then operates in the working directory

CNA snapshot 009d40f5  ·  Known Issues › Current bugs  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.

The initialised flag is set before the root is resolved, so after a failed directory creation every call returns an empty root, which the path helpers treat as '.', and containers and DeleteContainer then act in the working directory.

Identifier
CNA-BUG-151
Category
Bug
Subsystem
Storage
Status
Open
Verified against
CNA 009d40f5 (009d40f5dd085c4e674d3479675fac84b12b3e0a)
Severity
Medium (a triage suggestion, not a project priority)
Evidence basis
Source-verified: read at TARGET, not executed
Tests touching this area
Yes: see Current tests
Affected contract
Microsoft::Xna::Framework::Storage::StorageDevice::GetStorageRootEXT() (documented as the absolute storage root), SetAppNameEXT, EndOpenContainer, DeleteContainer and the device properties after a failed root resolution

Expected behaviour

GetStorageRootEXT() is documented to return the "Absolute path to the storage root". A root that cannot be created is reported with StorageDeviceNotConnectedException, and a device that cannot reach its storage keeps reporting that instead of silently choosing another location. Above all, DeleteContainer must never delete outside the storage root, which is the authority contract the containment work (REMED-CONTENT-002) established.

Actual behaviour at TARGET

EnsureStorageRoot in StorageDevice.cpp sets storageRootInitialized_ = true before computing the root. When create_directories fails (XDG_DATA_HOME naming a regular file, a read-only home directory, a name Windows cannot create), it throws StorageDeviceNotConnectedException with storageRoot_ still empty, and every later call returns the empty string without retrying. This includes the call inside SetAppNameEXT, which then throws before pointing Sharp Runtime's isolated storage at the new root. The core helpers treat an empty base as . (PathContainment.hpp; PathContainmentTest.EmptyBaseDirTreatsCurrentDirectoryAsBase pins that), so afterwards:

  • EndOpenContainer("Saves") creates ./Saves/AllPlayers in the working directory;
  • DeleteContainer(title) removes ./title recursively, so a title that matches a working-directory folder (the game's own Content folder, for example) deletes that folder;
  • getIsConnectedProperty reports false and the space properties report LLONG_MAX, because fs::exists("") is false;
  • the gamer-services store, which follows GetStorageRootEXT(), writes to ./GamerServices.

Only another SetAppNameEXT clears the latch.

Source locations

Evidence

Checked by reading StorageDevice.cpp, PathContainment.hpp and the storage tests at 009d40f5; not executed, and no storage test makes root creation fail. The same statement order is the root cause of the concurrent-first-use race another package publishes as CNA-BUG-150; this entry is the single-threaded failure path, which needs no second thread. The storage internals page records the latch.

Focused reproduction

# Illustrative (POSIX); not run. XDG_DATA_HOME names a regular file.
touch "$PWD/not-a-directory"
XDG_DATA_HOME="$PWD/not-a-directory" ./my_game
// Inside my_game; illustrative, not compiled.
try { StorageDevice::SetAppNameEXT("MyGame"); }          // throws: the root cannot be created
catch (const StorageDeviceNotConnectedException&) {}
auto device = StorageDevice::EndShowSelector(
    StorageDevice::BeginShowSelector(nullptr, nullptr).get());
auto open  = device->BeginOpenContainer("Saves", nullptr, nullptr);
auto saves = device->EndOpenContainer(open.get());      // creates ./Saves/AllPlayers
device->DeleteContainer("Content");                     // removes ./Content recursively

Current tests

StorageDeviceTests.cpp always resolves the root successfully; PathContainmentTests.cpp pins the empty-base behaviour that the failure path inherits. Nothing covers a failed first resolution.

Regression test

A test with XDG_DATA_HOME pointing at a regular file that expects SetAppNameEXT, GetStorageRootEXT, EndOpenContainer and DeleteContainer all to throw StorageDeviceNotConnectedException, with a sentinel in the working directory that must survive. The fix is to publish the flag only after storageRoot_ is assigned, or to refuse an empty root at every use.

Blast radius

Hosts where the per-user data root cannot be created (sandboxed or read-only home directories, a broken XDG_DATA_HOME, unusual Windows names): saves and the gamer-services store silently land in the working directory, and DeleteContainer can remove unrelated working-directory folders. Hosts with a creatable root are unaffected.

Workaround

Call StorageDevice::GetStorageRootEXT() once at startup and treat an exception or an empty result as fatal for storage.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Case study: storage limits
Known issues
Bug index