Case study: storage containment
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. Windows findings are quoted from CNA's commit records; the destructive storage fixture was not run for this page.
This exercise reconstructs the real path-containment fix in CNA commit abaed98b2 and audits what the storage code does in the TARGET snapshot. It teaches how to review a small-looking change whose failure mode is a recursive delete outside the application's own directory. The defect is historical and fixed; no source change is needed for the exercise, and the destructive storage fixture must not be run before its target directory has been checked.
The historical commit and what followed
Commit abaed98b2a5af1dfb4d5de69b76954c4a52cf153 (2026-07-20, “fix(Task REMED-CONTENT-002): fix fs::path containment at 3 sites, sweep repo for more”) is an ancestor of snapshot 009d40f5 and predates the v0.1.0-alpha.1 tag. It fixed three sites that joined caller- or file-supplied strings onto a base with std::filesystem::path::operator/ and no containment check:
StorageDevice::DeleteContainer(titleName), which recursively deletedroot / titleName;ContentReader::ReadExternalReference<T>()viaResolveRelativeAssetPath, where an absolute reference in a crafted.xnb/.cnjpassed through unchanged despite the method's own documentation promising it would be rejected;PlaylistParser::Parse, which accepted absolute and..-escaping.m3u/.m3u8entries.
At the time the code lived in the pre-modularization layout (src/Microsoft/Xna/Framework/Storage/StorageDevice.cpp, include/CNA/Internal/PathContainment.hpp). The same files now live in modules/storage/src/StorageDevice.cpp and modules/core/include/CNA/Internal/PathContainment.hpp, and several later commits (all ancestors of the snapshot) changed the code this exercise reviews:
| Commit | Date | What changed for this review |
|---|---|---|
c04faaedb | 2026-08-08 | REMED-CONTENT-007/008: content-root containment for the sites the original commit had recorded as out of scope |
54f90685a, 6dce0bac1 | 2026-08-12/13 | StorageDevice moved off SDL and decoupled from the platform module (the root is no longer SDL_GetPrefPath) |
1af12dac3 | 2026-08-31 | “confine container paths to their root”: StorageContainer names and file paths became contained |
822d3b960, e287fa0f9 | 2026-09-03 | SAMPLE-152: Sharp Runtime isolated storage scoped by game identity; Android app-private storage |
72d493c7f | 2026-09-16 | WINNATIVE-0028: “the path-containment guard did not hold on Windows” |
06b0eae4c, bf9b8561b, 790ae5438, cca978228 | 2026-09-16 | WINPORT-0007/0016: UTF-8 path handling without ANSI narrowing; is_absolute() is not the rootedness test on Windows |
History explains why an invariant was added; the snapshot's source proves what executes now. Do not paste the July 2026 patch onto today's module layout: the root policy, the helper and its callers have all moved since.
1. Turn a suspicious expression into a concrete invariant
The historical StorageDevice::DeleteContainer(titleName) called fs::remove_all(fs::path(EnsureStorageRoot()) / titleName) after checking only that titleName was not empty. In C++, joining an absolute right-hand path discards the left-hand base, and .. segments can climb above it. The invariant to state is not “paths look tidy”: the exact recursive-delete target must be a non-root descendant of the selected application storage root.
Ask the questions that turn the expression into a review: who controls titleName; how EnsureStorageRoot chooses the root; whether open and delete use the same root; whether a path component may be a symlink; and whether the string may have been authored on another operating system. The storage internals page gives the owner and the fake-async acquisition path; this case makes the review method concrete.
Caller titleName [untrusted text]
-> StorageDevice::DeleteContainer [empty name rejected]
-> EnsureStorageRoot [process-static root:
Android files dir / $XDG_DATA_HOME / %LOCALAPPDATA% / HOME chain / cwd]
-> CNA::Internal::ResolveContainedPath(root, titleName)
[separators normalized; rooted and drive-letter text rejected on the string;
lexical join; weakly_canonical check for symlinks; root itself rejected]
-> CNA::Internal::PathFromUtf8(resolvedPath) [UTF-8 to native path]
-> std::filesystem::remove_all [irreversible side effect]
Read the historical commit and the snapshot's StorageDevice.cpp side by side. The commit establishes that no DeleteContainer test existed before the fix. It also records that this behaviour was CNA-introduced, because FNA's DeleteContainer throws NotImplementedException; that is commit evidence for this one method, not a blanket licence to change other storage APIs.
What differs today: the historical root came from SDL_GetPrefPath with an XDG_DATA_HOME/HOME fallback. At the snapshot EnsureStorageRoot is independent of the windowing platform and resolves, in order, the Android package files directory, $XDG_DATA_HOME/<app>, %LOCALAPPDATA%\<app> (read through the wide Windows environment), ~/Library/Application Support/<app> on Apple or ~/.local/share/<app> elsewhere, and finally ./<app>; <app> is game unless SetAppNameEXT was called. The delete target is converted with PathFromUtf8; the source comment notes that the narrow overload would remove nothing on a non-ASCII Windows path, silently. The user-facing chain is documented in the Storage guide.
2. Design an oracle that cannot hide collateral damage
The historical tests did more than assert an exception. They placed a sentinel file under the storage root, called DeleteContainer with an empty name, an absolute name ("/etc"), a parent-escaping name ("../../../../../../../../tmp") and ".", and checked that the sentinel survived; the positive case checked that one named title disappeared while a sibling survived. The sentinel sits outside the authorized delete target: it would vanish if a mis-resolved call deleted the whole root. Note what it cannot see: whether a directory outside the root was touched. For the absolute and escaping cases that part of the claim rests on the thrown std::invalid_argument, which is why the rejection happens before any filesystem call.
The snapshot's StorageDeviceTests.cpp holds ten StorageDeviceDeleteContainerTest cases and four StorageDeviceNotConnectedExceptionSerializationTest cases:
| Test | What it pins |
|---|---|
EmptyTitleNameThrowsInvalidArgument | Empty name rejected |
AppNameAlsoScopesSharpRuntimeIsolatedStorage | SetAppNameEXT re-points Sharp Runtime's isolated-storage root at <root>/.cna_isolated_storage |
AbsoluteTitleNameThrowsAndDeletesNothing, EscapingTitleNameThrowsAndDeletesNothing, DotTitleNameThrowsAndDoesNotDeleteTheWholeRoot | The historical rejection cases with the in-root sentinel |
SimpleTitleNameDeletesOnlyThatContainer | Positive case with a surviving sibling |
ContainerOpenRejectsPathsOutsideStorageRoot | EndOpenContainer rejects "../outside" and an absolute display name |
ContainerOperationsRejectLexicalEscapes | Every StorageContainer file and directory operation rejects ../ |
ContainerOperationsRejectSymlinkEscapes | A directory symlink out of the container is refused; GTEST_SKIP when symlinks cannot be created |
ContainerAllowsNormalizedPathsThatRemainContained | saves/../profiles and profiles/../save.bin stay legal |
The generalizable technique is an outside-the-authorized-target sentinel, not a test that merely returns without throwing. For a real fix, run a new destructive test for the first time only in a disposable root, with a target whose resolved path has been inspected independently.
The fixture deletes a real per-user directory. SetUp calls StorageDevice::SetAppNameEXT("CnaTestsContent002StorageDevice") and uses GetStorageRootEXT(), so the root comes from the same environment chain as production (on Linux without XDG_DATA_HOME: ~/.local/share/CnaTestsContent002StorageDevice). TearDown calls fs::remove_all(root_) and then SetAppNameEXT(""), which re-resolves the root for the default name, creates <data root>/game as a side effect and re-points Sharp Runtime's isolated-storage override there. Before running StorageDeviceDeleteContainerTest.*, set XDG_DATA_HOME (or LOCALAPPDATA/HOME on the host that uses them) to a disposable directory. Do not infer that a test-named directory is harmless: the tests are part of the code under review.
3. Follow the helper beyond the original subsystem
The fix introduced the shared header that is now PathContainment.hpp. In July 2026 it had two functions; at the snapshot it has a small family, with one native core that every string variant wraps:
| Function | Role at the snapshot |
|---|---|
IsDisallowedAbsolutePath | String test, after \→/: anything starting with / (covers UNC) or a drive letter is rejected on every platform. Builds no path, so invalid UTF-8 cannot throw |
IsRootedPath | Native test (is_absolute() || has_root_name() || has_root_directory()); deliberately platform-specific |
ValidateContainedNativePath | Core check: lexical normalization, optional weakly_canonical on both sides, component comparison with lexically_relative; empty, . and ..-leading results fail; returns the lexical path |
ResolveContainedNativePathFromBase, ResolveContainedUtf8Path | Join untrusted UTF-8 text onto a native base and confine it to a native root |
ValidateContainedPath, ResolveContainedPathFromBase, ResolveContainedPath | String variants; results are generic (forward-slash) UTF-8 |
ResolveContainedPathRelativeToFile | Reference relative to a referring file: confined to the content root when the file is inside it, otherwise to the file's own directory (explicit external bundle) |
The returned form is architectural. PlaylistParser produces song paths that MediaLibrary::songByPath_ looks up. The original commit reports that its first version returned the canonicalized absolute path and broke those lookups, so canonicalization became check-only. A later Windows run found the second half of the same contract: lexically_normal() returned backslashes on Windows while every other producer of the key used /, so playlist lookups missed there; the string variants now return PathToGenericUtf8, and MediaLibraryIndex.cpp keys its index in the same generic form. A “safer” canonical return value would violate a data-flow invariant even if containment stayed correct.
The three original sites have different authority roots, and the snapshot adds more callers. A helper call is not a substitute for deciding what the root means:
| Call site at the snapshot | Helper | Authority root | Operation |
|---|---|---|---|
StorageDevice::DeleteContainer | ResolveContainedPath | Storage root | Recursive delete |
StorageContainer constructor (StorageContainer.cpp) | ResolveContainedPath on displayName + "/" + Player{N}|AllPlayers | Storage root | Directory creation |
StorageContainer::ResolveNativePath | ResolveContainedNativePathFromBase | The container directory | File create/open/delete/exists, directory create/remove |
ResolveRelativeAssetPath (ContentReader.cpp) | IsDisallowedAbsolutePath plus its own .. check | The content root's logical space; the join base is the current asset's directory | Read (external reference) |
Manifest sidecar fields (ContentManager.cpp) | ResolveContainedPath / ResolveContainedPathRelativeToFile | Content root or explicit external bundle | Read |
SongContentTypeReader, VideoContentTypeReader | ResolveContainedPathRelativeToFile | Content root or bundle | Read (media file) |
PlaylistParser::Parse (PlaylistParser.cpp) | ResolveContainedPath | The playlist's own directory | Read; escaping entries are skipped |
CnbModelFromCnj, ModelContentPipeline, XnbContentPipeline | ResolveContainedUtf8Path, ValidateContainedNativePath, IsDisallowedAbsolutePath | Build-time or model source roots | Read (content build) |
The original commit recorded two further findings as out of scope at the time: a duplicated ResolveRelativeFilePath() in the video and song readers with no containment, and eight unchecked .cnj/JSON manifest path fields in ContentManager.cpp (REMED-CONTENT-007/-008). Reading the snapshot shows both now go through the helper (commit c04faaedb). The commit also judged StorageContainer's joins FNA-faithful and left them; since 1af12dac3 they are contained too, documented in the source as an intentional security deviation from FNA's Path.Combine. That is the answer to “are the recorded sites still open?” for these files only; an exhaustive audit of every other path join in the tree was not made for this page.
4. State what the proof does not establish
Lexical and weak-canonical checks reject the tested absolute, traversal and symlink cases at validation time. They do not make the subsequent path-based remove_all atomic against another process changing filesystem entries between check and use. The repository tests prove the specified rejection behaviour in their fixture, not a race-resistant sandbox under a hostile concurrent process. Nor do they cover a concurrent change of the process-static root: storageRoot_, appName_ and storageRootInitialized_ are unsynchronized statics, and SetAppNameEXT changes them.
Host coverage is its own limit. CNA's records for 72d493c7f report that the containment guard did not hold on Windows until then (a path starting with a separator is not is_absolute() there, yet operator/ treats it as rooted), and that the defect was found by running the suite on real Windows, with nine PathContainmentTest failures. Non-ASCII paths need the same kind of native evidence; tools/platform/win32_unicode_paths.ps1 exists for that host. Nothing on this page was run on Windows. Write these as limits in the review memo instead of turning “unit tests pass” into a universal security claim.
A human patch review should answer, in writing:
- Which string is untrusted, and on which OS might it have been authored?
- What is the authority root, and is it the same as the join base?
- Does the check reject every rooted spelling on this host and cross-host text, before any path is built?
- Is canonicalization used for checking only, or also for returned keys?
- Which operations are destructive, and what exactly is their target?
- Which test fixture paths will be created or deleted, and on which environment variables do they depend?
- Which other modules consume this helper, and which output keys do they store?
- Has the code changed since the history being cited?
If those answers are written before editing, the patch can be reasoned about without an AI agent or the original author.
5. Repeat the investigation manually
- Compare the historical diff with the snapshot:
git show abaed98b2 -- src/Microsoft/Xna/Framework/Storage/StorageDevice.cpp include/CNA/Internal/PathContainment.hpp, thengit log --oneline abaed98b2..009d40f5 -- modules/storage modules/core/include/CNA/Internal/PathContainment.hpp. Identify which lines moved during modularization, the SDL removal and the Windows path work. - Read the snapshot's
EnsureStorageRoot,DeleteContainer, theStorageContainerconstructor andStorageContainer::ResolveNativePath, then the helper. Draw separate arrows for the authorized root, the join base and the returned UTF-8 or native target. - List every call site of the helper family at the snapshot (
git grep -n -E 'ResolveContained|ValidateContained|IsDisallowedAbsolutePath' 009d40f5 -- modules) and classify each as read, write or recursive delete. Do not assume one normalization rule suits them all. - Read
PathContainmentTests.cpp(30PathContainmentTestcases at the snapshot, against 14 in the original commit, including the Windows drive, drive-relative, rooted-backslash and UNC spellings rejected even on POSIX) and the storage fixture. Before running either, inspect any fixture that writes or deletes real paths. - Run isolated, focused filters and record passes, skips and missing hosts, for example
XDG_DATA_HOME=$(mktemp -d) ./cmake-build-unit/CnaStorageTests --gtest_filter='StorageDeviceDeleteContainerTest.*'and./cmake-build-unit/CnaCoreTests --gtest_filter='PathContainmentTest.*'after building thoseEXCLUDE_FROM_ALLtargets (cmake --build cmake-build-unit --target CnaStorageTests CnaCoreTests, or theunit-corebuild preset). Commands checked against the CMake sources; not executed here. - Write a one-page review note with the invariant, call path, owner, exact delete target, other consumers, test side effects and remaining uncertainty. If you cannot explain why playlist and content behaviour stay correct after a helper change, you are not ready to merge it.
This is the pattern for taking over CNA: history explains why an invariant was added; current source proves what executes now; tests provide bounded evidence; the human maintainer supplies the cross-module judgment. The same boundary appears as a blast-radius row in Blast radius and readiness and as a test route in What to test after changing X. The investigation method itself is in How to understand code you did not write.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-150: StorageDevice::EnsureStorageRoot publishes its initialised flag before the root, without synchronisation — The process-wide storage root is latched by a plain bool that is set before the path is computed, so a second thread that touches storage during the first resolution receives an empty root.
- CNA-BUG-151: StorageDevice::EnsureStorageRoot latches an empty root after a failed resolution, and storage then operates in the working directory — The initialised flag is set before the root is resolved, so after a failed directory creation every call returns an empty root, which the path helpers treat as '.', and containers and DeleteContainer then act in the work
- CNA-BUG-201: StorageDeviceDeleteContainerTest writes to and deletes inside the developer's real per-user data directory — The StorageDevice deletion tests resolve their fixture root from the real XDG_DATA_HOME/LOCALAPPDATA/HOME environment, remove_all() it in TearDown, and then recreate the default game's save directory, while a comment sti
- CNA-GAP-048: StorageContainer::OpenFile(file, mode, access, share) ignores its FileShare argument — The four-argument OpenFile accepts a FileShare value and discards it, because the Sharp Runtime FileStream it constructs has no share mode, so FileShare::None does not keep a second writer out.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Content architecture
- Internals
- Storage internals · Content runtime internals
- Maintainer workflow
- How to understand code you did not write · Add a regression test
- Tests and validation
- Storage and filesystem changes · Test architecture
- Reference
- Test target index