Storage internals

CNA snapshot 009d40f5  ·  Development › Module internals  ·  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. Checked by reading the TARGET sources, tests and CMake files and, for the Sharp Runtime coupling, a sibling Sharp Runtime checkout (next at 41b918c9, 2026-09-20; not pinned by TARGET). 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.

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
  -> SelectorResult { optional player, std::any state, EventWaitHandle (already signalled) }
  -> callback(result.get())                     runs BEFORE Begin returns
StorageDevice::EndShowSelector(result)         dynamic_cast check -> unique_ptr<StorageDevice>(player)
device->BeginOpenContainer(name, cb, state)    stores name + state only; callback inline
device->EndOpenContainer(result)               EnsureStorageRoot()   (may create the root, may throw)
  -> new StorageContainer(*this, name, root, player)   containment check + create_directories
container->CreateFile / OpenFile / ...         ResolveNativePath -> std::filesystem / Sharp Runtime FileStream

The four BeginShowSelector overloads funnel into two implementations. The sizeInBytes and directoryCount parameters are unnamed and ignored; the PlayerIndex overloads record the player, the others record none. 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 check only the dynamic type: a null or foreign IAsyncResult throws std::invalid_argument. They do not mark a result as consumed, so calling End twice yields two independent objects. A ContainerResult carries only the name and state, not the device that began the operation: EndOpenContainer opens the container for the player of the device it is called on. 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 also resolved (and possibly created) inside End, so StorageDeviceNotConnectedException surfaces there as well. 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_, all read and written by the private StorageDevice::EnsureStorageRoot.

  • Desktop, web and every non-Android build. 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 std::filesystem::current_path(). The order is the same on every host, so an XDG_DATA_HOME present in a Windows environment wins over LOCALAPPDATA. Emscripten takes this branch too, which is why browser saves land in the in-memory file system (see Tutorial 141: the web).
  • Android. The code first clears Sharp Runtime's isolated-storage override, then uses the parent of StoragePaths::GetIsolatedStorageRoot() plus the application name. Clearing first keeps a second SetAppNameEXT from nesting the new game's root below the old one; it also means the first lazy storage access on Android resets any override set earlier.
  • Creation. create_directories(root, error_code) runs; 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.

SetAppNameEXT stores the name (empty means game), clears the flag and the cached root, and immediately calls EnsureStorageRoot, so it creates the new directory and can throw. Only then does it point Sharp Runtime's isolated storage at <root>/.cna_isolated_storage through StoragePaths::SetIsolatedStorageRootOverride. That call 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, next at 41b918c9 of 2026-09-20 and not pinned by it, that default is .cna_isolated_storage in the working directory; on Emscripten it is /save/.cna_isolated_storage, where its source comment expects the application's own startup code to have mounted persistent storage, which CNA does not do, 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 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 calls EnsureStorageRoot 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.

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 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 latches an empty root. EnsureStorageRoot sets storageRootInitialized_ before it resolves anything. When resolution throws (directory creation fails, current_path throws, or on Windows a malformed name makes PathFromUtf8 throw), the first call throws, but storageRoot_ stays empty and every later call returns the empty string without retrying. Core's helpers treat an empty root as . (PathContainmentTest.EmptyBaseDirTreatsCurrentDirectoryAsBase pins that), so a later EndOpenContainer creates <working directory>/<name>/AllPlayers and DeleteContainer removes <working directory>/<title>, while getIsConnectedProperty reports false. Only another SetAppNameEXT resets it; after a failed SetAppNameEXT the Sharp Runtime override still points at the previous name's directory. Found by reading the source at 009d40f5; no test exercises this path and it was not executed.

⚠

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 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, then calls CNA::Internal::ResolveContainedPath(root, displayName + "/" + playerFolder), throws std::invalid_argument if that is not contained, stores the resolved generic UTF-8 text as storagePath_, and creates it with the throwing create_directories overload (an I/O failure here is a filesystem_error, not a StorageDeviceNotConnectedException). The source comment records why the creation is widened with PathFromUtf8: a narrow version created a mojibake-named sibling on Windows while every other member used the correct directory, so the container stayed 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 is stricter because it validates the title alone.

Entry pointAcceptedRefused with std::invalid_argument
EndOpenContainer (display name)relative names, nested names, internal .., .empty; /-rooted, drive-letter or UNC spellings on either host; .. escapes; an existing symlink leading out; text that cannot name a path on this host
container file and directory methods (path)relative paths, normalised internal paths such as saves/../profilesempty (checked first, per method); rooted spellings; .. escapes; the container root itself (., a/..); symlink escapes; malformed text
StorageDevice::DeleteContainer (title)a non-root descendant of the storage rootempty; rooted spellings; .. escapes; . and every other root-equal title; symlink escapes

Every container method that takes a path calls the private ResolveNativePath, which calls ResolveContainedNativePathFromBase(storagePath, storagePath, relative) 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 ... 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 are thin:

  • CreateDirectory calls create_directories; DirectoryExists is is_directory; FileExists is is_regular_file.
  • DeleteFile and DeleteDirectory both call std::filesystem::remove, which does not check the entry type: DeleteFile removes an empty directory and DeleteDirectory removes a file. A missing entry is a silent no-op and a non-empty directory throws filesystem_error.
  • GetFileNames and GetDirectoryNames take no untrusted path: they iterate the container root only (non-recursive), return PathToUtf8 of each file name, and 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 throws.
  • 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. The two- and three-argument OpenFile overloads forward to the four-argument one with ReadWrite defaults, and the four-argument overload ignores its FileShare. The Sharp Runtime checkout read alongside has no share parameter on FileStream at all (it opens a std::fstream on the native form of the UTF-8 string), so real sharing semantics are a lower-layer, cross-host change, not a one-line header edit.

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. Whether non-ASCII roots, titles and file names behave on a real Windows host remains unverified: no storage test uses a non-ASCII name, and core's Unicode path tests cover the helpers, not this module.

The recursive delete

StorageDevice::DeleteContainer(titleName) is the high-risk entry. It is an instance method but ignores the device's player: it removes <root>/<title> with every AllPlayers and PlayerN folder beneath it and anything else stored there. It checks for an empty title, calls ResolveContainedPath(EnsureStorageRoot(), titleName), throws std::invalid_argument when that is not contained, and then calls the throwing std::filesystem::remove_all(PathFromUtf8(resolved)), so an I/O error arrives as filesystem_error. The source comment (tagged REMED-CONTENT-002) records the defect this closed: an unchecked join let an absolute or ..-climbing title recursively delete anything the process could reach. It also notes that FNA's own DeleteContainer throws NotImplementedException, so no FNA behaviour constrains this code; XNA 4.0 does constrain it: its DeleteContainer resolves the same container path OpenContainer uses, including the device's Player{N} or AllPlayers folder, and deletes only that folder recursively, so a title used by several players loses one player's saves under XNA and all of them here. Historically, an earlier revision 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 that 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. But the root is shared: the gamer-services local store writes <root>/GamerServices/achievements and /leaderboards, so a save container titled GamerServices shares that directory and DeleteContainer("GamerServices") 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, std::any state, 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 and 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. Disposal does not close streams, and no file or directory method checks the disposed flag: operations after Dispose still work, and streams 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.29.0; 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, which the C++ overloads accept and ignore.
  • 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 share route (cna_storage_container_open_file_share) validates that only known FileShare bits are set and then passes the value to the C++ overload, which ignores it, so a share mask has no filesystem effect through either surface.
  • 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

StorageDeviceTests.cpp is the module's only test source: 14 GoogleTest definitions in two suites. The fixture StorageDeviceDeleteContainerTest has ten:

  • EmptyTitleNameThrowsInvalidArgument, AbsoluteTitleNameThrowsAndDeletesNothing, EscapingTitleNameThrowsAndDeletesNothing and DotTitleNameThrowsAndDoesNotDeleteTheWholeRoot pin the refusals, each with a sentinel file that must survive.
  • SimpleTitleNameDeletesOnlyThatContainer deletes one title and keeps a sibling.
  • AppNameAlsoScopesSharpRuntimeIsolatedStorage pins the .cna_isolated_storage coupling.
  • ContainerOpenRejectsPathsOutsideStorageRoot, ContainerOperationsRejectLexicalEscapes (all seven path-taking operations) and ContainerOperationsRejectSymlinkEscapes pin container authority; the symlink case calls GTEST_SKIP when the host cannot create directory symlinks.
  • ContainerAllowsNormalizedPathsThatRemainContained proves that normalisation does not over-refuse.

StorageDeviceNotConnectedExceptionSerializationTest has four: TheBaseStateSurvivesARoundTrip, TheInnerCauseTravelsWithIt, AnEmptyStoreStillConstructs and TheRestoredExceptionIsStillItsOwnType. The 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 and escape refusals. None was executed for this page.

⚠

Read the fixture before running it. SetUp calls SetAppNameEXT("CnaTestsContent002StorageDevice"), which resolves a directory under the real per-user data root; TearDown recursively removes that directory and then calls SetAppNameEXT(""), which recreates <data root>/game as a side effect. The fixture comment calls this an isolated, disposable root, but it is isolated by name only, and the CnaTests registration sets no storage environment. CApi_StorageSmoke shows the safe pattern: its CTest properties point XDG_DATA_HOME into the build tree. Because XDG_DATA_HOME is first in the chain on every host, the same override isolates the 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: Begin/End callback order, CompletedSynchronously, the wait handle or wrong-result rejection at the C++ level; the Player1..Player4 layout; Dispose, Disposing or use after disposal; the device properties; glob matching; the type-blind deletes; a C++ write and read round trip; the empty-root latch; FileShare; 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. Test callback ordering, CompletedSynchronously, the wait handle, wrong-result rejection and ownership; 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. Route every caller-supplied path through ResolveNativePath, keep the empty check, hand native paths to the filesystem, and add the method to both escape tests.

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 009d40f5; none was reproduced by running code.

  • A failed root resolution latches an empty root, after which storage silently works in the process working directory (root selection).
  • The three device properties build narrow paths from the UTF-8 root (root selection).
  • DeleteFile and DeleteDirectory do not check the entry type, and GlobMatch's ? matches a byte, not a character (container authority).
  • A save container titled GamerServices collides with the gamer-services local store (the recursive delete).
  • Container methods keep working after Dispose, and FileShare is ignored (ownership).
  • The storage tests write to the real per-user data root (testing).
  • Several source comments still describe StorageDevice as reaching the platform or the SDL preference path: CurrentPlatform.hpp, IPlatformFileSystem::GetPreferencesPath ("Backs StorageDevice"), the Game::GetPlatformEXT and X11Platform constructor 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: static root policy and its latch, the synchronous result types, construction in End, the recursive delete and the SetAppNameEXT coupling.
  3. StorageContainer.hpp, then StorageContainer.cpp: borrowed device lifetime, Dispose semantics, each path entry, GlobMatch and the stream handoff.
  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: read the fixture's real-directory teardown 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.