Skip to content

fix(variant): never adopt an identity reached by a truncated path - #274

Open
tobymurray wants to merge 3 commits into
UNAWatch:mainfrom
tobymurray:feat/inmemory-directory
Open

fix(variant): never adopt an identity reached by a truncated path#274
tobymurray wants to merge 3 commits into
UNAWatch:mainfrom
tobymurray:feat/inmemory-directory

Conversation

@tobymurray

@tobymurray tobymurray commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The bug

VariantConfig can adopt the wrong app's identity. Its sandbox-root scan builds each candidate path from an enumerated name, and ObjectInfo::name is as wide as a whole path, so "/" + name need not fit in one. A clipped path can name a different, existing file, and if that file is an alias the app comes up calling itself by another activity's name and logging that activity's FIT sport.

Measuring the name before building the path fixes it, and also clears the -Wformat-truncation this line has been earning at the -Os -Wall every app build uses. Recovering the length from snprintf's return would have fixed the behaviour but not the diagnostic, which is about the call rather than what the caller does with its result.

Why the test double is in here

The bug was invisible because nothing could reach it. InMemoryFileSystem's IDirectory is a stub whose readNext() always returns false, so code that walks a directory has its loop body unreachable from a test, and this scan is the only such caller in the tree. It had no host tests at all.

Making the fake enumerate is the first commit. The second is the one worth arguing about: once tests can lean on the fake, its habit of permitting states the device cannot reach stops being harmless. It would let one name be both a file and a directory, accept a removed path as a rename source and destroy whatever sat at the destination while returning true, and never fail a close(). Every refusal added there has a test that fails when the refusal is dropped, and each was settled by running the same call sequence against the simulator's Mock::FileSystem rather than against assumptions about what POSIX says.

Risk

One change reaches past the fake's own tests: close() now returns false for a handle that is not open, matching the simulator and FatFs. Fourteen production sites propagate that return, and none currently reaches close() on an unopened handle.

Summary by CodeRabbit

  • Bug Fixes

    • Improved application discovery when filenames exceed supported path lengths.
    • Overlong, unreadable, malformed, and mixed application entries are now handled more safely.
    • Prevented incomplete or stale paths from being opened during discovery.
    • Improved deterministic selection when multiple application aliases are present.
    • Improved handling of files, directories, and path variations during discovery.
  • Documentation

    • Added guidance for testing directory scanning, including sorting, snapshots, directory operations, failure cases, and filesystem differences.
  • Tests

    • Expanded coverage for application discovery and in-memory filesystem behavior.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds directory support to the in-memory host filesystem, documents and tests its enumeration semantics, and strengthens VariantConfig candidate-path handling with discovery tests.

Changes

Filesystem and variant testing

Layer / File(s) Summary
In-memory directory model
Tests/Host/support/KernelTestDoubles.hpp, Tests/Host/support/KernelTestDoubles.cpp, Libs/Header/SDK/Interfaces/IFileSystem.hpp
InMemoryFileSystem and InMemoryDirectory now support canonical paths, explicit and implied directories, sorted snapshot enumeration, directory operations, and metadata. ObjectInfo::name documentation now defines leaf-name and truncation behavior.
Directory behavior validation
Tests/Host/support/KernelTestDoubles_test.cpp, Docs/unit-testing.md
Tests and documentation cover listings, path handling, snapshots, ordering, lifecycle rules, metadata, and failure cases.
Variant candidate validation
Libs/Source/Variant/VariantConfig.cpp
Variant discovery checks candidate path length before reading flags and classifies overlong candidates as unreadable.
Variant discovery test coverage
Tests/Host/CMakeLists.txt, Tests/Host/variant/VariantConfig_test.cpp
The host target builds VariantConfig tests for identity selection, aliases, filtering, malformed payloads, overlong paths, stale-path prevention, and caller-default preservation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • UNAWatch/una-sdk#221: The PR extends and tests VariantConfig alias discovery and .uapp path handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.93% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing variant identity adoption through truncated paths.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
Tests/Host/support/KernelTestDoubles.cpp (3)

498-501: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

dir(nullptr) returns a root handle while file(nullptr) returns null.

file() returns an empty unique_ptr for a null path. dir() maps a null path to "", which names the root, so the caller receives a usable handle to the root directory. Align the two, or document the difference.

♻️ Proposed change
 std::unique_ptr<SDK::Interface::IDirectory> InMemoryFileSystem::dir(const char* path)
 {
-    return std::make_unique<InMemoryDirectory>(*this, path != nullptr ? path : "");
+    if (path == nullptr) {
+        return {}; // matches file(): a null path yields no handle
+    }
+    return std::make_unique<InMemoryDirectory>(*this, path);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Host/support/KernelTestDoubles.cpp` around lines 498 - 501, Update
InMemoryFileSystem::dir to return an empty unique_ptr when path is nullptr,
matching file() semantics; retain InMemoryDirectory construction for non-null
paths.

475-487: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

mkdir() accepts a path that already names a file.

mkdir() never checks files. If a test seeds a.txt and then calls mkdir("a.txt"), the same name is recorded as a file and as a directory. snapshot() then emits two entries with the same name, one with isDir false and one with isDir true. A real backend rejects that mkdir. A guard keeps the fake from producing a listing that the device cannot produce.

♻️ Proposed guard
 bool InMemoryFileSystem::mkdir(const char* path)
 {
     if (path == nullptr) {
         return false;
     }
+    // A file already occupying the name makes the directory impossible, as
+    // on device.
+    const std::string requested = normalizeDir(path);
+    auto existing = files.find(requested);
+    if (existing != files.end() && existing->second.exists) {
+        return false;
+    }
     // Creates parents too, and succeeds when the directory already exists --
     // both per IFileSystem::mkdir's contract.
-    for (std::string dir = normalizeDir(path); !dir.empty(); dir = parentOf(dir)) {
+    for (std::string dir = requested; !dir.empty(); dir = parentOf(dir)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Host/support/KernelTestDoubles.cpp` around lines 475 - 487, Update
InMemoryFileSystem::mkdir to check whether the normalized requested path already
exists in files before inserting it or its parents; return false for an existing
file, while preserving the current behavior for existing directories and parent
creation.

368-371: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider invalidating the enumeration state in setPath().

setPath() changes mPath but keeps mOpen, mEntries, and mCursor. If a caller calls setPath() while the handle is open, readNext() continues to return entries of the previous directory. A real backend cannot show that combination. Clearing the cursor state, or rejecting the call while open, keeps the fake honest.

♻️ Proposed change
 void InMemoryFileSystem::InMemoryDirectory::setPath(const char* path)
 {
     mPath = path != nullptr ? path : "";
+    // A retargeted handle must not keep serving the previous directory's
+    // snapshot.
+    mOpen = false;
+    mEntries.clear();
+    mCursor = 0;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Host/support/KernelTestDoubles.cpp` around lines 368 - 371, Update
InMemoryDirectory::setPath() to invalidate the existing enumeration state when
changing mPath: close the handle and clear mEntries and mCursor, or reject the
path change while mOpen is true. Ensure subsequent readNext() calls cannot
return entries from the previous directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Tests/Host/support/KernelTestDoubles_test.cpp`:
- Around line 86-99: Update the MkdirMakesAnEmptyDirectoryEnumerable test to
assert that the directory handle’s open() call succeeds directly before checking
listDir(fs, "Debug").empty(). Keep the empty-vector assertion for verifying
enumeration of a successfully opened empty directory, and apply the same change
to the corresponding test block noted in the comment.

In `@Tests/Host/support/KernelTestDoubles.cpp`:
- Around line 581-600: Update objectInfo() so both its file and directory
branches assign baseNameOf(path) to item.name, matching readNext() and FatFs
f_stat behavior. Keep the existing metadata assignments unchanged.

---

Nitpick comments:
In `@Tests/Host/support/KernelTestDoubles.cpp`:
- Around line 498-501: Update InMemoryFileSystem::dir to return an empty
unique_ptr when path is nullptr, matching file() semantics; retain
InMemoryDirectory construction for non-null paths.
- Around line 475-487: Update InMemoryFileSystem::mkdir to check whether the
normalized requested path already exists in files before inserting it or its
parents; return false for an existing file, while preserving the current
behavior for existing directories and parent creation.
- Around line 368-371: Update InMemoryDirectory::setPath() to invalidate the
existing enumeration state when changing mPath: close the handle and clear
mEntries and mCursor, or reject the path change while mOpen is true. Ensure
subsequent readNext() calls cannot return entries from the previous directory.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 665d7d28-c3e6-48c4-92dc-ae2e732e96d7

📥 Commits

Reviewing files that changed from the base of the PR and between 2feb7a9 and c1ec0a0.

📒 Files selected for processing (7)
  • Docs/unit-testing.md
  • Libs/Source/Variant/VariantConfig.cpp
  • Tests/Host/CMakeLists.txt
  • Tests/Host/support/KernelTestDoubles.cpp
  • Tests/Host/support/KernelTestDoubles.hpp
  • Tests/Host/support/KernelTestDoubles_test.cpp
  • Tests/Host/variant/VariantConfig_test.cpp

Comment thread Tests/Host/support/KernelTestDoubles_test.cpp
Comment thread Tests/Host/support/KernelTestDoubles.cpp Outdated
@tobymurray
tobymurray force-pushed the feat/inmemory-directory branch from c1ec0a0 to 98f41e6 Compare August 8, 2026 17:49
@tobymurray tobymurray changed the title Feat/inmemory directory test(host): enumerate directories in InMemoryFileSystem, and fix the variant-identity truncation it exposed Aug 8, 2026
@tobymurray
tobymurray force-pushed the feat/inmemory-directory branch 2 times, most recently from c4bc173 to 602b565 Compare August 8, 2026 18:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
Tests/Host/support/KernelTestDoubles_test.cpp (1)

587-645: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a case for a rename whose destination canonicalises to the source.

The suite covers a rename onto a different existing file at Line 642, but not a rename onto another spelling of the source itself. That case currently deletes the file and reports success. See the fix proposed on Tests/Host/support/KernelTestDoubles.cpp Lines 675-696.

💚 Suggested addition
     // Overwriting a plain file is still allowed, as std::rename does it.
     EXPECT_TRUE(fs.rename("src.txt", "blocker.txt"));
     EXPECT_EQ(fs.readFile("blocker.txt"), "SRC");
     EXPECT_FALSE(fs.exist("src.txt"));
+
+    // The same object under another spelling: a no-op success, not a deletion.
+    EXPECT_TRUE(fs.rename("blocker.txt", "/blocker.txt"));
+    EXPECT_TRUE(fs.exist("blocker.txt"));
+    EXPECT_EQ(fs.readFile("blocker.txt"), "SRC");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Host/support/KernelTestDoubles_test.cpp` around lines 587 - 645, Add a
test in RenameAndCopyRefuseADestinationThatCannotHoldAFile or a nearby rename
test covering a destination spelling that canonicalises to the source path, such
as a trailing-slash or equivalent normalized form. Assert rename reports success
without deleting the source, and verify the file remains readable and exists
afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Tests/Host/support/KernelTestDoubles.cpp`:
- Around line 675-696: Update InMemoryFileSystem::rename to return true
immediately when the canonical source and target keys are identical, without
modifying files. For different keys, move the source entry to target and erase
the source by canonical key rather than iterator, avoiding invalidation from
potential unordered_map rehashing.

---

Nitpick comments:
In `@Tests/Host/support/KernelTestDoubles_test.cpp`:
- Around line 587-645: Add a test in
RenameAndCopyRefuseADestinationThatCannotHoldAFile or a nearby rename test
covering a destination spelling that canonicalises to the source path, such as a
trailing-slash or equivalent normalized form. Assert rename reports success
without deleting the source, and verify the file remains readable and exists
afterward.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: eff058f3-74b2-4fa9-b940-23223240d01f

📥 Commits

Reviewing files that changed from the base of the PR and between c1ec0a0 and 20b21f6.

📒 Files selected for processing (7)
  • Docs/unit-testing.md
  • Libs/Header/SDK/Interfaces/IFileSystem.hpp
  • Libs/Source/Variant/VariantConfig.cpp
  • Tests/Host/support/KernelTestDoubles.cpp
  • Tests/Host/support/KernelTestDoubles.hpp
  • Tests/Host/support/KernelTestDoubles_test.cpp
  • Tests/Host/variant/VariantConfig_test.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • Docs/unit-testing.md
  • Libs/Source/Variant/VariantConfig.cpp

Comment thread Tests/Host/support/KernelTestDoubles.cpp
@tobymurray
tobymurray force-pushed the feat/inmemory-directory branch 3 times, most recently from e751d5a to dbf120a Compare August 8, 2026 19:37
@tobymurray tobymurray changed the title test(host): enumerate directories in InMemoryFileSystem, and fix the variant-identity truncation it exposed fix(variant): never adopt an identity reached by a truncated path Aug 8, 2026
@tobymurray
tobymurray force-pushed the feat/inmemory-directory branch from dbf120a to 96d93cc Compare August 8, 2026 19:54
`InMemoryFileSystem`'s `IDirectory` is a stub whose `readNext()` always
returns false, so SDK code that walks a directory has its loop body
unreachable from a test. `VariantConfig`'s sandbox-root scan is the only such
caller in the tree, and it has no host tests.

`InMemoryDirectory` enumerates the flat `files` map as a hierarchy: a path's
parent is everything before its last '/', so seeding "a/b/c.txt" makes "a"
and "a/b" enumerable with no mkdir call. Subdirectories come back with
`isDir` set, so an `if (item.isDir) continue;` guard is exercised rather than
silently dead. Both files and subdirectories are reported, and a listing
never repeats a name whatever mix of spellings and implied or explicit
directories produced it.

Enumeration is sorted by name. The backing store is an unordered_map, so
anything depending on order -- picking the "first" matching entry, say --
would otherwise be a coin flip between runs. Real FAT enumerates in
directory-entry order, so this is a guarantee the fake makes for the sake of
reproducible tests, not one the device makes.

`readNext()` is a cursor over a snapshot taken at `open()`, re-taken on an
explicit rewind, matching POSIX `rewinddir`. Entries added mid-scan do not
retroactively appear until then.

Around that: `mkdir()` creates parents and refuses a name a file already
holds, `IDirectory::create()` is the simulator's single non-recursive
`::mkdir` and does not invent parents, `remove()` takes an empty directory
and reports whether it removed anything, and `objectInfo()` fills `name` with
the leaf rather than the path handed in -- the convention every `readNext()`
already follows.

The deliberate divergences from a real backend are listed on the class and
mirrored in Docs/unit-testing.md, alongside a note on proving a scan is live:
a test expecting "nothing was found" cannot otherwise tell a correct decision
from an enumerator that never ran.
A fake is only as useful as its refusals. This one grants several that no
backend grants, so a test can build a state the device cannot reach and then
write assertions against it. Each was settled by running the same call
sequence against the simulator's Mock::FileSystem, which the host tests
already build, rather than against an assumption about what POSIX says.

One name could be both a file and a directory. `mkdir()` and
`IDirectory::create()` already refused a name a file held, but a write-mode
`open()` and `seedFile()` would plant a file over an existing directory, or
underneath another file. `canHoldFileAt()` now states that rule once and
`rename()` and `copy()` apply it to their destination too, standing in for
the backend's EISDIR and ENOTDIR. `seedFile()` returns bool to report the
refusal.

`rename()` and `copy()` took a tombstone as a source. Both looked the entry
up without checking `exists`, so a path this filesystem had already removed
still counted: `rename("gone.txt", "live.txt")` returned true and moved the
cleared entry onto live.txt, destroying a live file with a call that should
have been a no-op returning false. `remove()` reads that flag already; these
are the same rule. Their destination is checked as well -- the root, an
existing directory and a path under a file are refused, where a destination
of "" previously filed the entry under the root's own key and lost it --
while overwriting a plain file still works, as `std::rename` does it.

A source and destination that canonicalise to one key are the no-op success
`std::rename` makes them, rather than an entry moved onto itself, left
unspecified, and then erased. `rename()` erases by key rather than through
an iterator, which an intervening rehash may have invalidated.

`close()` could not fail. It returned true for a handle never opened, a file
never seeded, and a directory that does not exist, which left every
`EXPECT_TRUE(h->close())` an assertion with no failing mode. The simulator's
`File::close()` and `Directory::close()` both answer false there.

`IFile::setPath()` left an open handle open. `rename()` may keep one open,
being the same file under a new name, and migrates the open-handle count to
match; `setPath()` names a different file, so the count cannot follow.
Leaving it open stranded the old path's bucket and made the eventual
`close()` decrement the new path's from zero, underflowing size_t and taking
the leak instrumentation with it, while writes silently retargeted.
`IDirectory::setPath()` invalidates its scan for the same reason.

`canonicalPath()` collapses runs of '/', so "a//b" and "a/b" are one object
as on POSIX and FatFs. Splitting them left an empty path segment, which
enumerated as an entry with no name -- something no backend can show, and
which no caller could join back onto its parent.

`IDirectory::create()` on the root answers true, where `::mkdir` gives EEXIST
and the simulator reports success once it confirms the target is a directory.

Each refusal has a test that fails when it is dropped. Two of the additions
pin contracts that nothing currently violates -- the rehash path and copying
an object onto itself -- and say so, rather than implying coverage they do
not carry. The divergence lists on the class and in Docs/unit-testing.md are
reconciled and now record the boundary both listing guarantees stop at: a
name too long for ObjectInfo::name comes back clipped, so it names nothing,
and two names differing only past that capacity come back as one repeated
entry.
VariantConfig's sandbox-root scan decides what users see their activity
called. It builds each candidate path from an enumerated name, and
ObjectInfo::name is as wide as a whole path, so "/" + name need not fit in
one. A clipped path can name a different, existing file; if that file is an
alias, the app adopts its name and its FIT sport and comes up calling itself
the wrong thing.

The length is measured before the path is built, so there is nothing to
truncate. Recovering it from snprintf's return would fix the behaviour but
not the diagnostic: -Wformat-truncation fires on a "/%s" whose source is as
wide as its destination whatever the caller does with the result, and that is
the -Os -Wall cmake/una-app.cmake applies to every app build. Verified gone
for both host g++ and arm-none-eabi-g++.

The candidate buffer lives inside the scan loop, so an entry whose path is
declined names nothing rather than inheriting what the last entry left
behind. Ordering the conditions so the stale path is never read would work
today and is too fragile a thing to rest "never open the wrong .uapp" on.

The buffer is not the root cause and this does not pretend to fix it:
ObjectInfo::name is as wide as a whole path, so a maximal name can never be
joined onto a directory and still fit. Any future caller that scans and joins
meets the same wall, so the sizing relationship is spelled out on the field.

Also adds the suite this file never had. The mixed-directory determinism
rules had no coverage at all: a real .uapp beside an alias means classic
(tested with the real app both first and last in enumeration order, since
which is seen first decides whether a candidate is adopted before being
discarded), an unreadable candidate counts as real, non-apps and directories
are skipped, and a malformed alias -- unknown payload version, unknown
schema, bad JSON, zero-length config -- falls back to defaults rather than
bricking the launch.

Most of these expect the classic app, which is also the answer a scan that
saw nothing would give, so each asserts the sandbox root really enumerates
what was seeded before trusting the verdict. The truncation test carries a
positive control: the same alias payload, at the longest name that does fit,
is adopted -- so length is the only thing separating the two cases, and the
test cannot pass merely because the file was never reached.
@tobymurray
tobymurray force-pushed the feat/inmemory-directory branch from 96d93cc to 62ed35a Compare August 8, 2026 20:06
@tobymurray
tobymurray marked this pull request as ready for review August 8, 2026 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant