Storage internals

CNA snapshot 3b43bd86  ·  Development › Module internals  ·  source links pinned to 3b43bd86

✓

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_headers is linked PUBLIC because the public headers name PlayerIndex and the CNAEXT marker 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, Runtime and Threading are PUBLIC: the public headers expose System::IAsyncResult and its wait handle, System::EventHandler, System::IO::Stream with FileMode/FileAccess/FileShare, and System::Runtime::InteropServices::ExternalException with the serialization types. Storage is PRIVATE: SharpRuntime::Storage::StoragePaths is used only inside StorageDevice.cpp.
  • No platform edge. There is no cna_platform link and no platform include. The root is chosen from filesystem and environment policy, so the same save location logic runs under every CNA_PLATFORM value, including HEADLESS and TERMINAL.
  • A browser link interface. Under Emscripten the module puts -lidbfs.js and --pre-js WebStoragePre.js on its INTERFACE link 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=ON with the default CNA_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. EnvironmentVariableUtf8 reads XDG_DATA_HOME, then LOCALAPPDATA, then HOME; an unset or empty variable is skipped. On Windows it calls GetEnvironmentVariableW and converts through PathToUtf8, because std::getenv reads the ANSI environment block, where every character the code page cannot spell is already ? before any later conversion could recover it. Elsewhere it returns the std::getenv bytes. Each piece becomes a native path through PathFromUtf8; HOME gains Library/Application Support on Apple targets and .local/share otherwise; the last resort is the working directory, and when even that cannot be read the resolution throws StorageDeviceNotConnectedException("No per-user data directory or working directory to store in.") with the filesystem_error as inner exception. The order is the same on every host, so an XDG_DATA_HOME present in a Windows environment wins over LOCALAPPDATA.
  • Emscripten. The root is /cna-storage/<app>. WebStoragePre.js runs as a preRun hook before main(): it mounts IndexedDB-backed IDBFS with autoPersist at /cna-storage (for StorageDevice) and at /save (Sharp Runtime's default isolated-storage directory), holds a run dependency until FS.syncfs has restored the saved files, and only then sets Module.cnaStorageReady. ResolveStorageRoot asks for that flag on the page's thread (also from a worker game thread) and throws StorageDeviceNotConnectedException("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=OFF for 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 second SetAppNameEXT from nesting the new game's root below the old one without resetting anything another component configured.
  • Creation. Only EnsureStorageRoot creates the root: create_directories(root, error_code) runs, and a failure throws StorageDeviceNotConnectedException("Unable to create the storage directory.") with the filesystem_error as inner exception. On success the root is stored as PathToGenericUtf8, so GetStorageRootEXT returns 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; DeleteContainer creates 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 pointAcceptedRefused
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/../profilesafter 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:

  • CreateDirectory calls Sharp Runtime's System::IO::Directory::CreateDirectory, so a file in the way is an IOException and keeps its contents. DirectoryExists and FileExists are is_directory and is_regular_file with an error code: any failure, a path beneath a file or an unsearchable directory included, answers false.
  • DeleteFile calls System::IO::File::Delete: a directory is refused with UnauthorizedAccessException and kept, a missing parent directory is a DirectoryNotFoundException, and a missing file is a silent no-op. DeleteDirectory checks the entry first: a missing one is a DirectoryNotFoundException, a regular file an IOException, a non-empty directory an IOException with its contents kept, and a permission failure an UnauthorizedAccessException.
  • GetFileNames and GetDirectoryNames take no untrusted path: they list the container root only (non-recursive) through System::IO::Directory::GetFiles and GetDirectories, so a root that vanished is a DirectoryNotFoundException, and return each leaf name as UTF-8. The pattern overloads filter with the file-local GlobMatch. 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.
  • CreateFile and OpenFile convert the resolved path back to generic UTF-8 (ResolvePath) and construct System::IO::FileStream from that string. CreateFile uses FileMode::Create, ReadWrite and FileShare::None. The two-argument OpenFile uses Write access for Append and ReadWrite otherwise, the three-argument one the given access; both share None, as XNA does. The four-argument overload forwards its FileShare to the stream (21b4cd64c), and Sharp Runtime's FileStream enforces it: Windows applies the access and share masks, POSIX takes an advisory exclusive lock for None and a shared one otherwise, best-effort on filesystems without locking (the policy as storage.h documents it). A refused share is an IOException raised 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

ObjectCreated byOwned byHolds
SelectorResult, ContainerResultBegin*caller, unique_ptr<IAsyncResult>player or name, the beginning device (container results), std::any state, a consumed flag, signalled wait handle
StorageDeviceEndShowSelectorcaller, unique_ptran optional PlayerIndex; nothing else
StorageContainerEndOpenContainercaller, unique_ptrborrowed const StorageDevice*, display name, storagePath_ snapshot, disposed flag
System::IO::StreamCreateFile, OpenFilecaller, unique_ptrits own path and file handle; no reference to the container
application name and rootSetAppNameEXT, first useprocess staticssee 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 Begin and End in one call and refuses a player above Four and 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_ptr to its device resource and a stream resource keeps its container, so the borrowed pointer cannot dangle; cna_storage_device_destroy returns CNA_RESULT_INVALID_STATE while containers are open, cna_storage_container_destroy does the same while streams are open, and container destruction calls Dispose.
  • 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 DeleteContainer title, which the C++ method accepts as the root's own player folder.
  • The share route (cna_storage_container_open_file_share) validates that only known FileShare bits are set and passes the value to the four-argument C++ overload, which now enforces it; the shorter open routes and cna_storage_container_create_file are exclusive, as in C++. A sharing conflict returns CNA_RESULT_IO, leaves the output handle invalid and truncates nothing. Native exceptions reach C through the shared exception barrier (System.IO and UnauthorizedAccessException failures as I/O results).
  • Disposing registrations hold a weak reference to their container; DeviceChanged registrations 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:

  • AbsoluteTitleNameThrowsAndDeletesNothing and EscapingTitleNameThrowsAndDeletesNothing pin the refusals, each with a sentinel file that must survive; EmptyTitleNameDeletesOnlyTheRootPlayerFolder and DotTitleNameDeletesOnlyTheRootPlayerFolder pin XNA's reading of an empty or . title.
  • SimpleTitleNameDeletesOnlyThatContainer deletes one title and keeps a sibling.
  • AppNameAlsoScopesSharpRuntimeIsolatedStorage pins the .cna_isolated_storage coupling.
  • ContainerOpenRejectsPathsOutsideStorageRoot, ContainerOperationsRejectLexicalEscapes (all seven path-taking operations), ContainerOperationsRejectSymlinkEscapes and ContainerOperationsRejectDanglingSymlinkEscapes pin container authority; the symlink cases call GTEST_SKIP when the host cannot create symlinks.
  • ContainerAllowsNormalizedPathsThatRemainContained proves 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, CreateDirectory over a file, enumeration of a vanished root and of Unicode names, and file sharing (FileSharingIsForwardedToTheNativeStream walks 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:

  1. A path fix. Reproduce under a disposable root (XDG_DATA_HOME), add a failing test in the style of AbsoluteTitleNameThrowsAndDeletesNothing with a sentinel outside the intended target, then change the narrowest resolver or operation. Run CnaStorageTests and CnaCoreTests, 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 to PathContainment.hpp also reaches content's external references and media's playlist parsing: see content runtime internals.
  2. 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 called Begin. Update the C ABI selector route and its smoke test with it.
  3. 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 CnaTests process: both this fixture and GamerServicesStoreGuard set their own name first, and a new test must do the same.
  4. 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 preRun hook: a threaded build that keeps WasmFS has no persistent storage and StorageDevice reports 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 GamerServices shares 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 StorageDevice as reaching the platform or the SDL preference path: CurrentPlatform.hpp, IPlatformSystemServices::GetPreferencesPath ("Backs StorageDevice"), the Game::GetPlatformEXT documentation, a comment in the escape test and one in the CApi_StorageSmoke registration. At the snapshot, storage has no platform dependency; trust the code and the link-closure probe.

Curated source route

  1. storage/CMakeLists.txt, then ModuleProbes.cmake and probe_storage.cpp: the intentionally absent platform edge, the Sharp Runtime boundary, and the probe that enforces both.
  2. StorageDevice.hpp, then StorageDevice.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 in End, the per-player delete and the SetAppNameEXT resolver.
  3. StorageContainer.hpp, then StorageContainer.cpp: borrowed device lifetime, the disposed checks, ResolveBelow and the dangling-link check, each path entry and its System.IO error mapping, GlobMatch and the stream handoff with its sharing.
  4. StorageDeviceNotConnectedException.hpp: the ExternalException base and serialization through core's helper.
  5. PathContainment.hpp and PathUtf8.hpp, with their tests PathContainmentTests.cpp and UnicodePathResolutionTests.cpp: the shared security and encoding contract, to understand before changing storage alone.
  6. StorageDeviceTests.cpp and 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.
  7. CnaCApiStorage.cpp and StorageSmoke.c: how the C boundary repairs ownership and validates text.
  8. 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.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.