Storage internals
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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the snapshot's sources, tests and CMake files and, for the Sharp Runtime coupling, the sibling Sharp Runtime checkout at 530e217f (branch next), the revision CNA's own handoff names as needed by the storage repairs; it is newer than Sharp Runtime's v0.1.0 tag. No build or test was executed. Remaining unverified: non-ASCII paths on a real Windows host, concurrent root or application-name changes, and race-resistant filesystem confinement.
modules/storage/ owns CNA's policy for persisted user data: where the per-application root lives, how XNA's StorageDevice and StorageContainer names map onto directories, and which caller-supplied names may reach the filesystem at all. Its most important maintenance contract is authority: no title, container or file name supplied by a game may escape the chosen root, and the one recursive delete must stay below it. The public Begin/End API looks asynchronous but completes synchronously on the caller's thread. This page is for maintainers changing storage, the shared path helpers in core, or the C ABI storage routes; the game-facing contract is the Storage guide.
Module boundary and acquisition path
storage/CMakeLists.txt creates the static archive cna_storage with the alias CNA::Storage through cna_add_module, globbing exactly its own src/*.cpp: StorageDevice.cpp, StorageContainer.cpp and StorageDeviceNotConnectedException.cpp. Its edges are deliberately few:
- Core, headers only.
cna_core_headersis linkedPUBLICbecause the public headers namePlayerIndexand theCNAEXTmarker and the sources use core's header-only path helpers. No core symbol is referenced, so the core archive itself stays off the link line. - Sharp Runtime.
Core.Base,IO,RuntimeandThreadingarePUBLIC: the public headers exposeSystem::IAsyncResultand its wait handle,System::EventHandler,System::IO::StreamwithFileMode/FileAccess/FileShare, andSystem::Runtime::InteropServices::ExternalExceptionwith the serialization types.StorageisPRIVATE:SharpRuntime::Storage::StoragePathsis used only insideStorageDevice.cpp. - No platform edge. There is no
cna_platformlink and no platform include. The root is chosen from filesystem and environment policy, so the same save location logic runs under everyCNA_PLATFORMvalue, includingHEADLESSandTERMINAL. - A browser link interface. Under Emscripten the module puts
-lidbfs.jsand--pre-jsWebStoragePre.json itsINTERFACElink options, so every application that links storage mounts the browser save directories before its first XNA call. The options are omitted for a threaded build that keeps WasmFS (CNA_ENABLE_EMSCRIPTEN_THREADS=ONwith the defaultCNA_EMSCRIPTEN_USE_WASMFS=ON), because WasmFS cannot link IDBFS; see root selection.
The closure is checked by a test, not merely intended, although no CI job currently enforces it. ModuleProbes.cmake builds probe_storage.cpp against CNA::Storage alone and registers ModuleProbe_probe_storage plus ModuleLinkClosure_probe_storage, which fails when any other libcna_ archive, libCNA_, enet, FFmpeg or renderer library appears on the probe's link line (native test builds only, not Emscripten or Android; the closure check also needs Python 3 and a Makefiles generator, because it reads link.txt: under Ninja it reports SKIPPED with exit 77, and every CI job either uses Ninja or filters these tests out). Upward, games receive storage through the CNA umbrella in modules/CMakeLists.txt (cna_storage is one of its runtime parts, and part of libcna.so when CNA_SHARED_LIBRARY is on), CNA_GamerServices links it PUBLIC, and the C API links it privately. The module's place in the whole graph is on the physical module dependency map.
The public surface is the three XNA types plus two extensions. StorageDevice is final with a private constructor, so StorageDevice.hpp's EndShowSelector is the only way to obtain one. StorageContainer (a System::Object and System::IDisposable) also has a private constructor, with StorageDevice as its friend. StorageDeviceNotConnectedException derives from ExternalException and implements GetObjectData plus the protected serialization constructor through core's header-only ExceptionSerialization.hpp. The static SetAppNameEXT and GetStorageRootEXT are the CNA extensions. <windows.h> is confined to StorageDevice.cpp (with WIN32_LEAN_AND_MEAN and NOMINMAX, after the storage headers); the source comment records ERROR/min/max macro collisions from an earlier header inclusion, and StorageContainer's own CreateFile, DeleteFile and CreateDirectory are Win32 macro names too, which is one more reason never to include it from a storage header.
Begin/End completes before Begin returns
StorageDevice::BeginShowSelector(...) caller thread; no UI, no worker, no future
-> player checked (One..Four or 255), then sizeInBytes >= 0 ArgumentOutOfRangeException
-> SelectorResult { optional player, std::any state, EventWaitHandle (already signalled) }
-> callback(result.get()) runs BEFORE Begin returns
StorageDevice::EndShowSelector(result) type check, one-shot -> unique_ptr<StorageDevice>(player)
device->BeginOpenContainer(name, cb, state) stores name, state, this device and its player; callback inline
device->EndOpenContainer(result) type, one-shot, owner and empty-name checks; ResolveStorageRoot()
-> new StorageContainer(*this, name, root, player) containment check + Directory::CreateDirectory (whole chain)
container->CreateFile / OpenFile / ... disposed check, empty check, ResolveNativePath -> std::filesystem / Sharp Runtime IO
The four BeginShowSelector overloads funnel into one implementation. The overloads without a player pass the shared-storage sentinel 255, which XNA also accepts. Following XNA's IL, the player is checked first (anything other than One to Four or 255 throws System::ArgumentOutOfRangeException), then a negative sizeInBytes throws the same exception; directoryCount is not validated, as in XNA on the desktop, and neither value reserves anything. Both checks run before the callback, so a refused call invokes no callback. SelectorResult and ContainerResult are internal classes in StorageDevice.cpp: both answer true for IsCompleted and CompletedSynchronously, keep the caller's void* state in a std::any, and own a manual-reset EventWaitHandle constructed signalled, so waiting on the handle returns at once. The callback receives a raw pointer to the result that the caller is about to own through the returned unique_ptr; calling End inside the callback works (the usual XNA pattern), keeping that pointer beyond the owner's lifetime does not.
The End methods follow XNA's result contract. A null result or one from the other operation throws System::ArgumentNullException("result"). A matching result is consumed on its first End, and a second End throws System::InvalidOperationException, also when the first one failed later on. ContainerResult records the device that began the operation and its player, so EndOpenContainer on another device throws System::ArgumentException. Then, still before any filesystem access, an empty display name throws System::ArgumentNullException with XNA's message. The consequence for diagnosis matters most: BeginOpenContainer validates nothing, so for an empty, absolute or escaping name the completion callback has already run successfully when EndOpenContainer throws. The root is resolved inside End without being created; the container's constructor then creates the whole directory chain, so a file in the way or a missing permission surfaces there as a System.IO exception, and StorageDeviceNotConnectedException surfaces from End only when no root can be resolved at all. Start at End, not at Begin. Do not add a worker thread because a method is called Begin: the process-global root state below has no synchronization to offer one.
Root selection and global identity
The resolution order for game authors is the table in the persistence-path chain. This section is about how StorageDevice.cpp holds that answer. Three process statics carry it: appName_, storageRoot_ and storageRootInitialized_. Two private functions read them. ResolveStorageRoot returns the cached root or computes it without touching the filesystem; EndOpenContainer and DeleteContainer use it, so opening and deleting containers never pre-create the root. EnsureStorageRoot resolves, creates the directory and caches the result; GetStorageRootEXT, the three device properties and the Sharp Runtime isolated-storage resolver use it.
- Desktop and every build other than Android and Emscripten.
EnvironmentVariableUtf8readsXDG_DATA_HOME, thenLOCALAPPDATA, thenHOME; an unset or empty variable is skipped. On Windows it callsGetEnvironmentVariableWand converts throughPathToUtf8, becausestd::getenvreads the ANSI environment block, where every character the code page cannot spell is already?before any later conversion could recover it. Elsewhere it returns thestd::getenvbytes. Each piece becomes a native path throughPathFromUtf8;HOMEgainsLibrary/Application Supporton Apple targets and.local/shareotherwise; the last resort is the working directory, and when even that cannot be read the resolution throwsStorageDeviceNotConnectedException("No per-user data directory or working directory to store in.")with thefilesystem_erroras inner exception. The order is the same on every host, so anXDG_DATA_HOMEpresent in a Windows environment wins overLOCALAPPDATA. - Emscripten. The root is
/cna-storage/<app>.WebStoragePre.jsruns as apreRunhook beforemain(): it mounts IndexedDB-backed IDBFS withautoPersistat/cna-storage(forStorageDevice) and at/save(Sharp Runtime's default isolated-storage directory), holds a run dependency untilFS.syncfshas restored the saved files, and only then setsModule.cnaStorageReady.ResolveStorageRootasks for that flag on the page's thread (also from a worker game thread) and throwsStorageDeviceNotConnectedException("Persistent browser storage is unavailable.")when it is not set, rather than saving into memory that a reload would discard. A threaded build that keeps WasmFS never sets the flag, so its storage reports unavailable; configure-DCNA_EMSCRIPTEN_USE_WASMFS=OFFfor persistence there. Under Emscripten's Node runner, which has no IndexedDB, the hook keeps the process-local file system and reports storage ready. The user-level view is Tutorial 141: the web. - Android. The root is the parent of
StoragePaths::GetDefaultIsolatedStorageRootPath()(Sharp Runtime's platform default, the package's internal files directory, read without applying any override or resolver and without creating anything) plus the application name. Reading the unmodified default keeps a secondSetAppNameEXTfrom nesting the new game's root below the old one without resetting anything another component configured. - Creation. Only
EnsureStorageRootcreates the root:create_directories(root, error_code)runs, and a failure throwsStorageDeviceNotConnectedException("Unable to create the storage directory.")with thefilesystem_erroras inner exception. On success the root is stored asPathToGenericUtf8, soGetStorageRootEXTreturns forward slashes on every host. Opening a container creates the root as part of the container's own directory chain (see container authority), with XNA's System.IO exceptions instead;DeleteContainercreates nothing.
SetAppNameEXT stores the name (empty means game), clears the flag and the cached root, and installs a resolver in Sharp Runtime through StoragePaths::SetIsolatedStorageRootResolver that answers EnsureStorageRoot() / ".cna_isolated_storage". It resolves and creates nothing and cannot fail on an unavailable root: since the museum B11 repair (fffa552ad) the application identity is configuration, and an unavailable root is reported by the first access that needs it, whether that is a CNA container, GetStorageRootEXT or System.IO isolated storage. A function-local static unregisters the resolver at static destruction, so Sharp Runtime never calls back into an unloaded module. That resolver is the only place the coupling is established. On a desktop build a game that never calls SetAppNameEXT leaves System.IO isolated storage at Sharp Runtime's own default, which is not below the CNA root (in the sibling Sharp Runtime checkout read alongside the snapshot, 530e217f on next, that default is .cna_isolated_storage in the working directory; on Emscripten it is /save/.cna_isolated_storage, which CNA's storage preRun hook mounts as persistent IDBFS, and on Android it is under the package's internal files directory). Nothing in the runtime sets the name automatically: outside tests, the only callers at the snapshot are games, one gamer-services example and the C ABI. The name is treated as trusted configuration: it is joined with operator/ and never passes through the containment helpers, so an absolute name replaces the base and .. climbs out of the data directory.
The statics are unsynchronized, and the source makes no promise about a concurrent first use or a concurrent name switch. The supported rule is the header's: set the name once at startup (the header suggests the Game constructor) before any storage activity, and do not switch it while containers are open or other threads persist data. A switch affects the three kinds of holder differently. A StorageDevice holds only its optional player and resolves the root on every operation, so a device obtained before the switch opens later containers under the new root. A StorageContainer captured storagePath_ at construction and keeps using the old directory. The gamer-services local store in LocalGamerServicesStore.cpp recomputes <root>/GamerServices/ from GetStorageRootEXT on each access and follows the switch; in a browser that places local achievements and leaderboards under the persistent /cna-storage mount. The gamer-services local profile list (profiles.json) is resolved by that module from its own environment chain, not from this root, so it is outside both browser mounts (a source reading; not run in a browser for this page).
The device-level XNA surface is thin. DeviceChanged is a static event that nothing in the snapshot raises (the C ABI only lets callers subscribe and unsubscribe). getIsConnectedProperty ensures (and so creates) the root, walks up to the nearest existing ancestor and swallows every exception as false; it is not removable-media detection. getFreeSpaceProperty and getTotalSpaceProperty return std::filesystem::space of the root, LLONG_MAX when the root does not exist, and wrap other failures in StorageDeviceNotConnectedException.
A failed root resolution is retried, not latched. EnsureStorageRoot sets storageRootInitialized_ only after the root has been resolved and created, and ResolveStorageRoot never caches. When resolution throws (directory creation fails, neither an environment variable nor the working directory is available, on Windows a malformed name makes PathFromUtf8 throw, or browser storage is not ready), that call throws, storageRoot_ keeps no partial value, and the next storage call resolves again. This matters because core's helpers treat an empty root as . (PathContainmentTest.EmptyBaseDirTreatsCurrentDirectoryAsBase pins that): an empty cached root would send EndOpenContainer and DeleteContainer into the working directory, so keep the flag after the assignment in any change to this function. StorageDeviceLazyRootTest.AFailedAccessDoesNotCacheAnUnavailableRoot pins the retry through Sharp Runtime's first access, and StorageResidualPathsTest.AnUnresolvableRootIsADisconnectedDevice pins the exception when no root can be resolved (desktop POSIX builds; not executed for this page).
Three properties narrow the root. getFreeSpaceProperty, getTotalSpaceProperty and getIsConnectedProperty hand the UTF-8 std::string root to fs::exists, fs::space and fs::path through the narrow constructor, which the core path model says reads through the ANSI code page on Windows. Every other storage path goes through PathFromUtf8. With a non-ASCII root these three can query a different path (for example, report LLONG_MAX free space). Source-level observation; not exercised on Windows.
Container authority, UTF-8 and destructive operations
EndOpenContainer passes the device, the display name, the resolved (not yet created) root and a player index to the private constructor in StorageContainer.cpp: -1 becomes the folder AllPlayers, a PlayerIndex becomes Player1 to Player4 (PlayerIndex::One is 0). The constructor rejects an empty display name with System::ArgumentNullException, then resolves displayName + "/" + playerFolder below the root through the private ResolveBelow, which throws System::ArgumentException if that is not contained, stores the resolved generic UTF-8 text as storagePath_, and creates it with Sharp Runtime's System::IO::Directory::CreateDirectory, which builds the whole chain, the storage root included, as XNA's DirectoryInfo.Create does. Its System.IO failures escape unwrapped: a file occupying any level (the data folder, the application folder, the container name or the player folder) is a System::IO::IOException and the file is kept, and a directory that cannot be created for lack of permission is a System::UnauthorizedAccessException. Directory takes the UTF-8 path and widens it itself, so the directory created is the one every other member resolves; an earlier narrow creation had made a mojibake-named sibling on Windows and left the container permanently empty.
Because the check covers the name and the player folder together, a display name is a relative path, not a single component. Saves/Slot1 nests; backslashes are normalised to / before parsing on every host, so a\b nests on POSIX too; . or x/.. places the container directly at <root>/AllPlayers. Only names that escape the root are refused. DeleteContainer resolves exactly the same title-plus-player-folder path, so it accepts what EndOpenContainer accepts and, as in XNA, also an empty title, which addresses the player folder at the root itself.
| Entry point | Accepted | Refused |
|---|---|---|
EndOpenContainer (display name) | relative names, nested names, internal .., . | empty, System::ArgumentNullException before the root is touched; with System::ArgumentException: /-rooted, drive-letter or UNC spellings on either host, .. escapes, an existing symlink leading out, a final dangling symlink whose target lies outside, text that cannot name a path on this host |
| container file and directory methods (path) | relative paths, normalised internal paths such as saves/../profiles | after the disposed check, empty with System::ArgumentNullException (parameter file or directory); with System::ArgumentException: rooted spellings, .. escapes, the container root itself (., a/..), symlink escapes including dangling ones, malformed text. A component the filesystem cannot inspect is not refused: the operation fails with the filesystem's own error |
StorageDevice::DeleteContainer (title) | any title whose player folder stays below the root, including empty and . (the root's own player folder) | with System::ArgumentException: rooted spellings, .. escapes, symlink escapes |
Every container method that takes a path first throws System::ObjectDisposedException on a disposed container, then refuses an empty argument, then calls the private ResolveNativePath, which hands the container directory and the relative text to ResolveBelow. That calls ResolveContainedUtf8Path from PathContainment.hpp with canonical checking on. In order: backslashes become /; empty text and any IsDisallowedAbsolutePath spelling (leading separator or drive letter) are rejected on the string, before a path is built; TryPathFromUtf8 turns text that cannot name a path here into a refusal instead of an exception; the join is lexically normalised; root and candidate are passed through weakly_canonical for the check only; the relative remainder must be non-empty, not ., and must not start with ... Two storage-side steps follow. weakly_canonical cannot follow a final symbolic link whose target does not exist yet, so DanglingLinkStaysBelow follows such a link chain (up to 40 hops) and refuses it when the place the filesystem would create the file lies outside (a24c75b2e). And when the canonical check fails only because a component cannot be inspected (no search permission, a symlink loop, an overlong name), ResolveBelow repeats the check lexically and returns the lexical path, so the operation fails with the filesystem's error, as XNA's does, while a lexical or symlink escape is still refused. The returned path is the lexical join, not the canonical one. The full helper contract and its tests belong to core: see Core: path conversion and containment. After resolution the methods follow XNA's System.IO behaviour:
CreateDirectorycalls Sharp Runtime'sSystem::IO::Directory::CreateDirectory, so a file in the way is anIOExceptionand keeps its contents.DirectoryExistsandFileExistsareis_directoryandis_regular_filewith an error code: any failure, a path beneath a file or an unsearchable directory included, answersfalse.DeleteFilecallsSystem::IO::File::Delete: a directory is refused withUnauthorizedAccessExceptionand kept, a missing parent directory is aDirectoryNotFoundException, and a missing file is a silent no-op.DeleteDirectorychecks the entry first: a missing one is aDirectoryNotFoundException, a regular file anIOException, a non-empty directory anIOExceptionwith its contents kept, and a permission failure anUnauthorizedAccessException.GetFileNamesandGetDirectoryNamestake no untrusted path: they list the container root only (non-recursive) throughSystem::IO::Directory::GetFilesandGetDirectories, so a root that vanished is aDirectoryNotFoundException, and return each leaf name as UTF-8. The pattern overloads filter with the file-localGlobMatch. That matcher is byte-wise and case-sensitive, so?matches one byte, not one non-ASCII character. An empty pattern returns no matches without touching the filesystem.CreateFileandOpenFileconvert the resolved path back to generic UTF-8 (ResolvePath) and constructSystem::IO::FileStreamfrom that string.CreateFileusesFileMode::Create,ReadWriteandFileShare::None. The two-argumentOpenFileusesWriteaccess forAppendandReadWriteotherwise, the three-argument one the given access; both shareNone, as XNA does. The four-argument overload forwards itsFileShareto the stream (21b4cd64c), and Sharp Runtime'sFileStreamenforces it: Windows applies the access and share masks, POSIX takes an advisory exclusive lock forNoneand a shared one otherwise, best-effort on filesystems without locking (the policy asstorage.hdocuments it). A refused share is anIOExceptionraised before an existing file is truncated. The matrix records this repair (ISSUE-152) as awaiting validation on further platforms, and the browser's virtual file system has no native sharing at all.
UTF-8 handling follows the core model in PathUtf8.hpp: API strings are UTF-8, PathFromUtf8 widens them to UTF-16 on Windows (throwing on malformed text) and keeps the bytes unchanged on POSIX, and untrusted names go through TryPathFromUtf8. The C++ surface does not validate UTF-8 up front or reject an embedded NUL; the C ABI does both before it calls in. Two storage tests now use non-ASCII names (an empty Unicode directory deleted, Unicode names enumerated), but whether non-ASCII roots, titles and file names behave on a real Windows host remains unverified.
The recursive delete
StorageDevice::DeleteContainer(titleName) is the high-risk entry. Since the ISSUE-010 and museum B11 repairs it is XNA 4.0's deletion: it resolves the root without creating it, resolves titleName + "/" + playerFolder for this device's player (AllPlayers or Player1 to Player4) through ResolveBelow, and removes only that folder with everything below it. Another player's folder, the named folder itself and anything else stored beside them stay. When the target is not a directory (a missing container, or a file of that name) the call returns without deleting or creating anything. std::filesystem::remove_all runs with an error code, and a failure is mapped as for DeleteDirectory: DirectoryNotFoundException, UnauthorizedAccessException (for example an undeletable file below it) or IOException. The one deliberate difference from XNA is containment: XNA's Path.Combine would follow an absolute or ..-climbing title, which CNA refuses with System::ArgumentException. The source comment records why: an unchecked join once let such a title recursively delete anything the process could reach. Historically, an earlier revision also handed the UTF-8 string to remove_all's narrow overload, which on Windows could silently delete nothing for a non-ASCII root; the snapshot converts first. The reconstruction of the containment fix is the storage containment case study.
Two facts bound the blast radius further. A title that is itself a symbolic link leading out of the root is refused by the canonical check, and std::filesystem::remove_all removes symbolic links it meets inside the tree rather than following them. The root is shared with the gamer-services local store, which writes <root>/GamerServices/achievements and /leaderboards: a save container titled GamerServices lives in the same directory, but because DeleteContainer("GamerServices") now removes only that device's player folder, it no longer deletes the local achievements and leaderboards.
Containment is path-based, not race-resistant. Validation and use are separate steps on path text: the check canonicalises the components that exist at that moment, and the operation (or FileStream) resolves the lexical path again afterwards. A symbolic link swapped in between is not shown to be defeated by this code, and neither is a concurrent root change. Do not describe storage as a race-proof sandbox; race-resistant filesystem confinement remains unverified.
Ownership, lifetime and threads
| Object | Created by | Owned by | Holds |
|---|---|---|---|
SelectorResult, ContainerResult | Begin* | caller, unique_ptr<IAsyncResult> | player or name, the beginning device (container results), std::any state, a consumed flag, signalled wait handle |
StorageDevice | EndShowSelector | caller, unique_ptr | an optional PlayerIndex; nothing else |
StorageContainer | EndOpenContainer | caller, unique_ptr | borrowed const StorageDevice*, display name, storagePath_ snapshot, disposed flag |
System::IO::Stream | CreateFile, OpenFile | caller, unique_ptr | its own path and file handle; no reference to the container |
| application name and root | SetAppNameEXT, first use | process statics | see root selection |
The only use of the borrowed device pointer is getStorageDeviceProperty; every file operation uses storagePath_. Keep the device alive while that getter can be called. Dispose is idempotent: it sets the flag first and then raises Disposing once, with the container as sender, on the thread that disposes; the destructor calls it when needed. The destructor is not declared noexcept(false), so a Disposing handler that throws during destructor-driven disposal ends in std::terminate. Since 5865ae938 a disposed container refuses use as XNA's does: every file and directory method, both enumerations and the DisplayName and StorageDevice getters throw System::ObjectDisposedException before validating their arguments or touching the filesystem, also from inside a Disposing handler; IsDisposed, the type name and a repeated Dispose stay available. Disposal does not close streams or delete files: streams already open stay valid after both container and device are gone. The module has no mutex, no worker and no thread-affinity check; every callback runs on the calling thread before Begin returns. How these rules sit among the engine's other lifetimes and callbacks is on the ownership and lifetime master map and the thread and callback map.
The C ABI storage routes
CnaCApiStorage.cpp and storage.h expose storage through the experimental C ABI (built only with CNA_BUILD_C_API=ON, default OFF; ABI 0.50.0 at 3b43bd86; source-level, not build-verified here). The adapter repairs at its boundary what the C++ surface leaves to the caller:
- Each selector route runs
BeginandEndin one call and refuses a player aboveFourand a negative size or directory count as an invalid argument before calling in. The C++ overloads now refuse an invalid player and a negative size themselves but, like XNA, accept any directory count. - A container resource keeps a
shared_ptrto its device resource and a stream resource keeps its container, so the borrowed pointer cannot dangle;cna_storage_device_destroyreturnsCNA_RESULT_INVALID_STATEwhile containers are open,cna_storage_container_destroydoes the same while streams are open, and container destruction callsDispose. - Names and paths are validated as UTF-8 with embedded NUL refused before they reach storage; a failure is reported as an encoding result, not as an exception from the container. The C route still refuses an empty
DeleteContainertitle, which the C++ method accepts as the root's own player folder. - The share route (
cna_storage_container_open_file_share) validates that only knownFileSharebits are set and passes the value to the four-argument C++ overload, which now enforces it; the shorter open routes andcna_storage_container_create_fileare exclusive, as in C++. A sharing conflict returnsCNA_RESULT_IO, leaves the output handle invalid and truncates nothing. Native exceptions reach C through the shared exception barrier (System.IO andUnauthorizedAccessExceptionfailures as I/O results). Disposingregistrations hold a weak reference to their container;DeviceChangedregistrations target the static event.
A change to C++ storage semantics therefore needs the matching route and its smoke test reviewed too; the adapter's general machinery is described in C API internals.
Testing and safe human changes
The module has seven test sources with 70 GoogleTest definitions at this snapshot, all under modules/storage/tests/Microsoft/Xna/Framework/Storage/. StorageDeviceTests.cpp is the oldest and largest (31). Its fixture StorageDeviceDeleteContainerTest has 27:
AbsoluteTitleNameThrowsAndDeletesNothingandEscapingTitleNameThrowsAndDeletesNothingpin the refusals, each with a sentinel file that must survive;EmptyTitleNameDeletesOnlyTheRootPlayerFolderandDotTitleNameDeletesOnlyTheRootPlayerFolderpin XNA's reading of an empty or.title.SimpleTitleNameDeletesOnlyThatContainerdeletes one title and keeps a sibling.AppNameAlsoScopesSharpRuntimeIsolatedStoragepins the.cna_isolated_storagecoupling.ContainerOpenRejectsPathsOutsideStorageRoot,ContainerOperationsRejectLexicalEscapes(all seven path-taking operations),ContainerOperationsRejectSymlinkEscapesandContainerOperationsRejectDanglingSymlinkEscapespin container authority; the symlink cases callGTEST_SKIPwhen the host cannot create symlinks.ContainerAllowsNormalizedPathsThatRemainContainedproves that normalisation does not over-refuse.- Sixteen more pin the System.IO parity of the container: empty arguments and patterns, the disposed check before them, directory and file deletion errors,
CreateDirectoryover a file, enumeration of a vanished root and of Unicode names, and file sharing (FileSharingIsForwardedToTheNativeStreamwalks every access and share combination,ShortFileOverloadsAndCreateFileAreExclusive,DeniedSharingDoesNotTruncateStoredData,TwoArgumentAppendUsesWriteOnlyAndPreservesThePrefix; the three native-sharing cases are compiled out under Emscripten).
StorageDeviceNotConnectedExceptionSerializationTest has four: TheBaseStateSurvivesARoundTrip, TheInnerCauseTravelsWithIt, AnEmptyStoreStillConstructs and TheRestoredExceptionIsStillItsOwnType. The six newer sources are StorageDeviceAsyncContractTests.cpp (9: synchronous completion and the signalled wait handle, player and size validation order, one-shot End including inside the callback, wrong-owner and empty-name ordering, the shared-player sentinel), StorageDeviceLazyRootTests.cpp (5: naming creates nothing, unavailable roots fail on access, Sharp Runtime's first access, no cached failure), StorageContainerCreationTests.cpp (7: the whole chain is created; a file at any level is an IOException; permission failures are UnauthorizedAccessException), StorageContainerLifetimeTests.cpp (4: the event contract and the fifteen refused access paths), StorageContainerPathErrorsTests.cpp (7) and StorageResidualPathsTests.cpp (7: per-player deletion, missing or file-shaped containers, unsearchable directories, an unresolvable root). Permission cases skip under root and several fixtures are compiled only for desktop POSIX or non-Windows hosts. Every source is compiled into the focused CnaStorageTests executable (an EXCLUDE_FROM_ALL developer target, not a separate CTest registration) and into the aggregate CnaTests, whose cases UnitTests.cmake registers with gtest_discover_tests(DISCOVERY_MODE PRE_TEST) and the source tree as working directory. Related coverage lives elsewhere: PathContainmentTests.cpp (30 definitions) and UnicodePathResolutionTests.cpp (19) in CnaCoreTests; the two probe_storage CTests above; and CApi_StorageSmoke (StorageSmoke.c, C ABI builds only), which counts selector callbacks, copies the root, writes eight bytes through a container stream and reads them back, and checks deletion, escape and sharing refusals. None was executed for this page.
Read the fixtures before running them. StorageDeviceDeleteContainerTest::SetUp calls SetAppNameEXT("CnaTestsContent002StorageDevice") and resolves that directory under the real per-user data root; TearDown recursively removes it and then calls SetAppNameEXT("") (which, since the deferred root, creates nothing). The asynchronous-contract, lifetime and path-error tests create randomly named containers below whatever root is current, normally <data root>/game, and remove only their own. The fixture comment calls this an isolated, disposable root, but it is isolated by name only, and the CnaTests registration sets no storage environment. The creation, lazy-root and residual-path fixtures show the safe pattern: they point XDG_DATA_HOME at a fresh temporary directory, as the CApi_StorageSmoke CTest properties point it into the build tree. Because XDG_DATA_HOME is first in the chain on every host, the same override isolates the whole C++ suite.
# shown for reference; not executed for this page
cmake --preset unit
cmake --build cmake-build-unit --target CnaStorageTests
XDG_DATA_HOME="$PWD/cmake-build-unit/storage-test-home" ./cmake-build-unit/CnaStorageTests
The suite does not establish: glob matching beyond * (EnumerationPreservesUnicodeNamesAndReturnsOnlyLeafNames checks *.bin, directory-* and patterns that match nothing; no test uses ? or a case difference); the free- and total-space properties; the browser IDBFS mount and its readiness flag; FileShare on Windows or on a filesystem without locking (the matrix keeps ISSUE-152 open for platform validation); concurrent name switching or multi-threaded container use; device-change signalling; race-resistant confinement; or non-ASCII paths on Windows.
Change routes that keep the authority contract intact:
- A path fix. Reproduce under a disposable root (
XDG_DATA_HOME), add a failing test in the style ofAbsoluteTitleNameThrowsAndDeletesNothingwith a sentinel outside the intended target, then change the narrowest resolver or operation. RunCnaStorageTestsandCnaCoreTests, then the full suite, plus a Windows check with a non-ASCII root for any native-path change. For every destructive call, write down the validated root and the exact resolved target before merging. A change toPathContainment.hppalso reaches content's external references and media's playlist parsing: see content runtime internals. - A Begin/End change. Extend
StorageDeviceAsyncContractTests.cpp, which pins callback ordering,CompletedSynchronously, the wait handle, the one-shot and owner rules and the exception types; do not add a worker merely because the method is calledBegin. Update the C ABI selector route and its smoke test with it. - An application-name or root change. Test Sharp Runtime isolated storage as well as CNA containers and the gamer-services store. Static state leaks between tests that share one
CnaTestsprocess: both this fixture andGamerServicesStoreGuardset their own name first, and a new test must do the same. - A new container method. Check disposal first, then the empty argument, route every caller-supplied path through
ResolveNativePath, map filesystem failures to XNA's System.IO exceptions, hand native paths to the filesystem, and add the method to both escape tests and to the lifetime test's list of refused access paths.
Where such a test belongs in the tree and how to register it is covered by I need to add a regression test and test architecture and change recipes.
Current gaps at this snapshot
Each item was found by reading the source at 3b43bd86; none was reproduced by running code.
- Browser persistence depends on the link-time
preRunhook: a threaded build that keeps WasmFS has no persistent storage andStorageDevicereports unavailable, and the browser round trip is recorded by CNA's own run, not by a CTest (root selection). - The three device properties build narrow paths from the UTF-8 root (root selection).
GlobMatch's?matches a byte, not a character; the tests exercise only*patterns (StorageDeviceTests.cpp), so neither?nor case-insensitive matching is pinned (container authority).- A save container titled
GamerServicesshares its directory with the gamer-services local store; deletion no longer reaches the store's files (the recursive delete). - Native file sharing (ISSUE-152) is recorded by CNA as awaiting validation on further platforms, and the browser's virtual file system has none (container authority).
- Four of the seven storage test fixtures write below the real per-user data root (testing).
- Several source comments still describe
StorageDeviceas reaching the platform or the SDL preference path:CurrentPlatform.hpp,IPlatformSystemServices::GetPreferencesPath("BacksStorageDevice"), theGame::GetPlatformEXTdocumentation, a comment in the escape test and one in theCApi_StorageSmokeregistration. At the snapshot, storage has no platform dependency; trust the code and the link-closure probe.
Curated source route
storage/CMakeLists.txt, thenModuleProbes.cmakeandprobe_storage.cpp: the intentionally absent platform edge, the Sharp Runtime boundary, and the probe that enforces both.StorageDevice.hpp, thenStorageDevice.cpp: the resolve-only and creating root functions and the retry rule, the browser branch, the synchronous result types and their one-shot rules, the deferred construction inEnd, the per-player delete and theSetAppNameEXTresolver.StorageContainer.hpp, thenStorageContainer.cpp: borrowed device lifetime, the disposed checks,ResolveBelowand the dangling-link check, each path entry and its System.IO error mapping,GlobMatchand the stream handoff with its sharing.StorageDeviceNotConnectedException.hpp: theExternalExceptionbase and serialization through core's helper.PathContainment.hppandPathUtf8.hpp, with their testsPathContainmentTests.cppandUnicodePathResolutionTests.cpp: the shared security and encoding contract, to understand before changing storage alone.StorageDeviceTests.cppand the six newer sources beside it: read each fixture's root handling before executing it, then map each claim on this page to its oracle.CnaCApiStorage.cppandStorageSmoke.c: how the C boundary repairs ownership and validates text.LocalGamerServicesStore.cpp: the other writer below the same root.
For where storage sits among all modules, see the module index; for the maintainer task routes, the Maintainer Handbook.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Framework services and ecosystem quick reference — A map of CNA's input, audio, media, device, network, gamer-services, storage and sharp-runtime types with their current boundaries and owner pages, plus the bindings and showcase applications around CNA.
- GamerServices behaviour contract — What CNA's GamerServices does with local offline profiles and with a configured CNA service: sign-in, configuration and credentials, the offline store, achievements, leaderboards, the Guide and the dispatcher.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-047: StorageContainer::GetFileNames/GetDirectoryNames(searchPattern): GlobMatch treats ? as one byte and compares case-sensitively on every host — The hand-written matcher behind the pattern overloads matches ? against one UTF-8 byte rather than one character and compares bytes case-sensitively even on Windows, where XNA's Directory.GetFiles matching is not; only s
- CNA-BUG-150: StorageDevice::EnsureStorageRoot caches the process-wide storage root in unsynchronised statics — The storage root is cached in a plain static bool and std::string with no lock or atomic, so two threads touching storage for the first time both resolve it and write the string concurrently, a data race, and a reader ma
- CNA-BUG-152: StorageDevice::getFreeSpaceProperty, getTotalSpaceProperty and getIsConnectedProperty pass the UTF-8 root to std::filesystem through the narrow path constructor — The three device properties hand the UTF-8 root string straight to fs::exists, fs::space and fs::path, which on Windows reinterpret it in the ANSI code page, so with a non-ASCII root they query a different path.
- CNA-BUG-242: Source comments still say StorageDevice reaches the platform or the SDL preference path — IPlatformFileSystem::GetPreferencesPath is documented as backing StorageDevice and several comments cite storage as a reason for the ambient platform, but storage has no platform dependency and GetPreferencesPath has no
- CNA-BUG-279: The sibling version check accepts sharp-runtime's v0.1.0 tag, but this snapshot needs sharp-runtime APIs added after it (a FileStream constructor and XmlSerializer property members), so such a build fails to compile instead of failing configuration — cmake/DependencyVersions.cmake requires sharp-runtime 0.1.0 or a later 0.1.x patch, which the v0.1.0 tag satisfies, yet StorageContainer.cpp calls a FileStream constructor sharp-runtime gained in c6984ece and XmlSerializ
- CNA-GAP-049: StorageDevice::DeviceChanged is declared but never raised — The static event exists for API compatibility, but nothing in the snapshot raises it; genuine XNA 4.0 raises it through FrameworkCallbackLinker.StorageDeviceChanged when a storage device changes, and CNA's desktop storag
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Storage guide · Storage: the persistence-path chain · Storage: path containment · Tutorial 141: saving on the web
- Architecture
- Architecture overview
- Internals
- Core: path conversion and containment · Content runtime internals · Gamer services internals · C API internals
- Maintainer workflow
- Case study: storage containment · Ownership and lifetime master map · Thread and callback map · I need to add a regression test · Maintainer Handbook
- Tests and validation
- Test architecture and change recipes
- Reference
- Module index · Test target index · Physical module dependency map