diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1a945..b1780ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,18 @@ ### Added - Added `AnyStore`, a type-erased wrapper that selects any store backend (`FileStore`, `HeapStore`, or custom) at runtime and works with `TypedStore`. +- Added one path model shared by every filesystem adapter: a path is always relative to the adapter's root (`"/a"`, `"./a"` and `"a"` are the same file), and the base path says where that root is. See "Paths and base paths" in the user guide. Path resolution never allocates; its buffers are sized by the new `USTORE_MAX_PATH_LEN` (default 128) and `USTORE_MAX_BASEPATH_LEN` (default 32) macros. + +### Changed + +- **Behavior change (native `PosixFileSystem` / `StdioFileSystem` / `UniversalFileSystem`):** with the default (empty) base path, absolute paths now resolve under the current directory instead of the host's root, the same way relative paths already did. For example, `"/tmp/x"` now means `./tmp/x`. To use host-absolute paths, construct the adapter with `"/"` as the base path, e.g. `PosixFileSystem("/")`. Relative paths such as `"./kv/"` resolve exactly as before. +- **Behavior change (native `PosixFileSystem` / `StdioFileSystem`):** a non-empty base path is now honored. It was previously ignored, so every path now resolves under it. ### Fixed +- Fixed relative paths (`"./x"`) failing on the ESP32 `LittleFSFileSystem`, `SPIFFSFileSystem` and `SDFileSystem` adapters, whose Arduino FS layer only accepts paths that start with `/`. This also stops their `init()` write check (on `"./__init_test__"`) from failing, which reformatted the filesystem on every `init()` call when `reformatOnFail` was set (the default). +- Fixed the POSIX and stdio adapters ignoring their base path, which also left relative paths unresolvable on ESP32 with a non-empty mount point (#13). +- Fixed adapters keeping the caller's base-path pointer, which dangled if the string was temporary, and fixed the LittleFS mount point dangling after the adapter was destroyed, because the Arduino FS layer keeps that pointer (#13). - Fixed `FileStore` compaction running with the active segment still open, which LittleFS and FatFS refuse to delete, and fixed the store being left without a writable segment (or with a stray out-of-range segment file) after a failed compaction (#6, #11). - Fixed compaction recovery paths that could lose data: a power loss after the compacted segment was moved into place but before the journal was cleared deleted all data on the next boot; a failed rename while finalizing deleted all data and still reported success; recovery from a compaction interrupted mid-way kept a stale index, and a later compaction attempt could truncate the only copy of the interrupted compaction's records. A compaction interrupted by a write failure is now recovered immediately rather than at the next boot. diff --git a/CLAUDE.md b/CLAUDE.md index c69b41d..3f866a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,20 @@ microStore is a persistent key-value store for embedded systems, inspired by Bit `File` accumulates a running CRC-32 on every read/write transparently; call `file.crc()` to retrieve it. To add a new platform, subclass `FileSystemImpl` and `FileImpl`. +### Paths + +One path model for every adapter and platform, implemented in `include/microStore/Path.h`: + +- **Paths are root-relative.** `/a`, `./a` and `a` name the same file under the adapter's root. +- **The base path is the root.** An empty base means the backend's default root: the cwd on native, the fallback mount on ESP32, and the filesystem root elsewhere. Native host-absolute paths need base `"/"`. +- **Adapters resolve every path argument** with `USTORE_RESOLVE_PATH(var, in, fail)`, which uses a stack buffer of `USTORE_MAX_PATH_LEN` and calls the adapter's private `resolve()`: + - ESP32 VFS and nRF52 adapters use `Path::normalize` (no copy when the path is already canonical); the framework adds the mount point. + - Posix/Stdio use `Path::join(base, p)`, with base `"."` when empty on native. +- **Keep the path code allocation-free:** no `std::string`. + - Base paths are copied into `char[USTORE_MAX_BASEPATH_LEN]` members with `Path::copy_base`. + - Mount points passed to `LittleFS.begin()` must come from `Path::persistent_mount()`, because Arduino's `FSImpl` keeps the raw pointer. +- **Don't resolve in the `FileSystem` wrapper:** the VFS adapters would get the mount point added twice. + ### Platform Detection - `PLATFORM_NATIVE`: uses `std::chrono` for timestamps diff --git a/docs/microstore_user_guide.md b/docs/microstore_user_guide.md index f821e13..8d2d4c5 100644 --- a/docs/microstore_user_guide.md +++ b/docs/microstore_user_guide.md @@ -188,6 +188,35 @@ Use it when the choice between persistent and volatile storage depends on runtim Adding a new backend means subclassing `FileSystemImpl` and `FileImpl`. The `File` class accumulates a running CRC-32 on every read/write transparently — call `file.crc()` to retrieve it. +### 4.1 Paths and base paths + +Every adapter, on every platform, treats paths the same way: + +- **A path is always relative to the adapter's root.** `"/cfg"`, `"./cfg"` and `"cfg"` all name the same thing, `/cfg`. Pick whichever spelling you like; the same string works unchanged on native hosts and on MCUs. +- **The base path says where the root is.** Leave it empty (the default) for the backend's natural root: + +| Adapter | Root with the default (empty) base path | Root with base path `B` | +|---------|------------------------------------------|-------------------------| +| `PosixFileSystem`, `StdioFileSystem` (native) | the current working directory | host directory `B` (relative `B` is under the current directory) | +| `PosixFileSystem`, `StdioFileSystem` (ESP32) | LittleFS, mounted as ESP-IDF's fallback filesystem | LittleFS mounted at `B` (must start with `/`, e.g. `"/littlefs"`) | +| `LittleFSFileSystem` (ESP32) | LittleFS, mounted as ESP-IDF's fallback filesystem | LittleFS mounted at `B` | +| `SPIFFSFileSystem`, `SDFileSystem`, `InternalFSFileSystem`, `FlashFSFileSystem` | the filesystem's root | — (no base-path parameter) | + +`".."` is passed through as-is: the base path is a root, not a security sandbox. + +**Reaching the host's real root on native.** Give it as the base path: + +```cpp +using namespace microStore; +FileSystem app { Adapters::PosixFileSystem() }; // "./kv/" and "/kv/" → /kv/ +FileSystem host{ Adapters::PosixFileSystem("/") }; // "/tmp/x" → /tmp/x +FileSystem data{ Adapters::PosixFileSystem("/var/lib/myapp") }; // "/kv/" → /var/lib/myapp/kv/ +``` + +`FileSystem` objects are cheap handles, so use two of them when one part of a program stores data under its own root and another part needs absolute host paths. + +**Limits and memory.** Path resolution never allocates. Each filesystem call resolves its path into a stack buffer of `USTORE_MAX_PATH_LEN` bytes (two for `rename()`), and the base path is copied into a fixed `USTORE_MAX_BASEPATH_LEN` buffer inside the adapter, so the caller's string needn't outlive the call. A resolved path that doesn't fit makes that call fail; it is never truncated. A base path that doesn't fit makes `init()` and every call fail. On ESP32, the LittleFS mount point lives in one static buffer for the life of the program, because the Arduino FS layer keeps the pointer it is given. LittleFS is a single shared filesystem, so the first adapter to `init()` fixes its mount point; a later adapter that asks for a different one logs a warning and uses the existing mount. + --- ## 5. Quick Start @@ -626,6 +655,8 @@ All have sane defaults; override only if you have a reason. | `USTORE_MAX_KEY_LEN` | `64` | Maximum key length in bytes | | `USTORE_MAX_VALUE_LEN` | `1024` | Maximum value length in bytes | | `USTORE_MAX_FILENAME_LEN` | `64` | Maximum total length of generated filenames | +| `USTORE_MAX_PATH_LEN` | `128` | Maximum length of a resolved adapter path, base path included; one stack buffer of this size per filesystem call (see §4.1) | +| `USTORE_MAX_BASEPATH_LEN` | `32` | Maximum length of an adapter base path / mount point | | `USTORE_COMPACT_RETRY_MS` | `60000` | Cooldown between failed compaction attempts | | `USTORE_COMPACT_THRESHOLD` | `25` | % of dead records that triggers auto-compaction (0 to disable) | | `USTORE_DEFAULT_TTL_SECS` | `0` | Initial global TTL (0 = disabled) | diff --git a/include/microStore/Adapters/FlashFSFileSystem.h b/include/microStore/Adapters/FlashFSFileSystem.h index 753f7a5..ad3cb02 100644 --- a/include/microStore/Adapters/FlashFSFileSystem.h +++ b/include/microStore/Adapters/FlashFSFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #include #include @@ -226,7 +227,8 @@ class FlashFSFileSystem : public microStore::FileSystem { } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); int pmode; switch (mode) { // Read only. File must exist. ("r") @@ -276,26 +278,32 @@ class FlashFSFileSystem : public microStore::FileSystem { } - virtual bool exists(const char* path) override { + virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return FlashFS.exists(path); } - virtual bool remove(const char* path) override { + virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return FlashFS.remove(path); } - virtual bool rename(const char* from_path, const char* to_path) override { + virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return FlashFS.rename(from_path, to_path); } - virtual bool mkdir(const char* path) override { + virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!FlashFS.mkdir(path)) { return false; } return true; } - virtual bool rmdir(const char* path) override { + virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!FlashFS.rmdir_r(path)) { return false; } @@ -303,7 +311,8 @@ class FlashFSFileSystem : public microStore::FileSystem { } - virtual bool isDirectory(const char* path) override { + virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); Adafruit_SPIFlash_LittleFS::File file(FlashFS); if (file.open(path, Adafruit_SPIFlash_LittleFS::FILE_O_READ)) { bool is_directory = file.isDirectory(); @@ -313,8 +322,9 @@ class FlashFSFileSystem : public microStore::FileSystem { return false; } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); Adafruit_SPIFlash_LittleFS::File root = FlashFS.open(path); if (!root) { return files; @@ -345,6 +355,15 @@ class FlashFSFileSystem : public microStore::FileSystem { Adafruit_FlashTransport_SPI _transport; Cached_SPIFlash _flash; bool _mounted = false; + + private: + // Paths are root-relative ("/a/b", "./a/b" and "a/b" are the same file); + // the framework adds the mount point. + inline const char* resolve(const char* path, char* buf, size_t n) const { + const char* resolved = Path::normalize(path, buf, n); + if (resolved == nullptr) USTORE_LOG("[ustore] FlashFSFileSystem: path too long: %s\n", path); + return resolved; + } }; }; diff --git a/include/microStore/Adapters/InternalFSFileSystem.h b/include/microStore/Adapters/InternalFSFileSystem.h index de39793..a989b4a 100644 --- a/include/microStore/Adapters/InternalFSFileSystem.h +++ b/include/microStore/Adapters/InternalFSFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #include @@ -166,7 +167,8 @@ class InternalFSFileSystem : public microStore::FileSystem { } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); int pmode; switch (mode) { // Read only. File must exist. ("r") @@ -217,26 +219,32 @@ class InternalFSFileSystem : public microStore::FileSystem { } - virtual bool exists(const char* path) override { + virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return InternalFS.exists(path); } - virtual bool remove(const char* path) override { + virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return InternalFS.remove(path); } - virtual bool rename(const char* from_path, const char* to_path) override { + virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return InternalFS.rename(from_path, to_path); } - virtual bool mkdir(const char* path) override { + virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!InternalFS.mkdir(path)) { return false; } return true; } - virtual bool rmdir(const char* path) override { + virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!InternalFS.rmdir_r(path)) { return false; } @@ -244,7 +252,8 @@ class InternalFSFileSystem : public microStore::FileSystem { } - virtual bool isDirectory(const char* path) override { + virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); Adafruit_LittleFS_Namespace::File file(InternalFS); if (file.open(path, Adafruit_LittleFS_Namespace::FILE_O_READ)) { bool is_directory = file.isDirectory(); @@ -254,8 +263,9 @@ class InternalFSFileSystem : public microStore::FileSystem { return false; } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); Adafruit_LittleFS_Namespace::File root = InternalFS.open(path); if (!root) { return files; @@ -307,6 +317,15 @@ class InternalFSFileSystem : public microStore::FileSystem { return (totalBytes() - usedBytes()); } + + private: + // Paths are root-relative ("/a/b", "./a/b" and "a/b" are the same file); + // the framework adds the mount point. + inline const char* resolve(const char* path, char* buf, size_t n) const { + const char* resolved = Path::normalize(path, buf, n); + if (resolved == nullptr) USTORE_LOG("[ustore] InternalFSFileSystem: path too long: %s\n", path); + return resolved; + } }; }; diff --git a/include/microStore/Adapters/LittleFSFileSystem.h b/include/microStore/Adapters/LittleFSFileSystem.h index ff22757..7c0f314 100644 --- a/include/microStore/Adapters/LittleFSFileSystem.h +++ b/include/microStore/Adapters/LittleFSFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #include @@ -85,7 +86,9 @@ class LittleFSFileSystem : public microStore::FileSystem { class FileSystemImpl : public microStore::FileSystemImpl { public: - FileSystemImpl(const char* basepath = "") : _basepath(basepath) {} + FileSystemImpl(const char* basepath = "") { + _basepath_ok = Path::copy_base(_basepath, sizeof(_basepath), basepath); + } virtual ~FileSystemImpl() {} public: @@ -101,8 +104,18 @@ class LittleFSFileSystem : public microStore::FileSystem { inline virtual bool init(bool reformatOnFail = true) override { USTORE_LOG("[ustore] Initializing LittleFSFileSystem\n"); - // Initialize LittleFS - if (!LittleFS.begin(true, _basepath)) { + if (!_basepath_ok) { + USTORE_LOG("[ustore] LittleFSFileSystem base path longer than %d characters!\n", USTORE_MAX_BASEPATH_LEN - 1); + return false; + } + // Initialize LittleFS. The base path is the mount point; Arduino + // keeps the pointer, so pass storage that outlives this adapter. + bool conflict = false; + const char* mount = Path::persistent_mount(_basepath, &conflict); + if (conflict) { + USTORE_LOG("[ustore] WARNING: LittleFS already mounted at \"%s\", ignoring base path \"%s\"\n", mount, _basepath); + } + if (!LittleFS.begin(true, mount)) { USTORE_LOG("[ustore] Failed to initialize LittleFSFileSystem!\n"); return false; } @@ -129,7 +142,8 @@ class LittleFSFileSystem : public microStore::FileSystem { } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); const char* pmode; switch (mode) { // Read only. File must exist. ("r") @@ -165,30 +179,37 @@ class LittleFSFileSystem : public microStore::FileSystem { } - inline virtual bool exists(const char* path) override { + inline virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return LittleFS.exists(path); } - inline virtual bool remove(const char* path) override { + inline virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return LittleFS.remove(path); } - inline virtual bool rename(const char* from_path, const char* to_path) override { + inline virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return LittleFS.rename(from_path, to_path); } - inline virtual bool mkdir(const char* path) override { + inline virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!LittleFS.mkdir(path)) return false; return true; } - inline virtual bool rmdir(const char* path) override { + inline virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!LittleFS.rmdir(path)) return false; return true; } - virtual bool isDirectory(const char* path) override { + virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); fs::File file = LittleFS.open(path, FILE_READ); if (file) { bool is_directory = file.isDirectory(); @@ -198,8 +219,9 @@ class LittleFSFileSystem : public microStore::FileSystem { return false; } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); fs::File root = LittleFS.open(path); if (!root) { return files; @@ -226,7 +248,16 @@ class LittleFSFileSystem : public microStore::FileSystem { } private: - const char* _basepath = ""; + char _basepath[USTORE_MAX_BASEPATH_LEN]; + bool _basepath_ok = false; + + // Paths are root-relative ("/a/b", "./a/b" and "a/b" are the same file); + // the framework adds the mount point. + inline const char* resolve(const char* path, char* buf, size_t n) const { + const char* resolved = Path::normalize(path, buf, n); + if (resolved == nullptr) USTORE_LOG("[ustore] LittleFSFileSystem: path too long: %s\n", path); + return resolved; + } }; }; diff --git a/include/microStore/Adapters/PosixFileSystem.h b/include/microStore/Adapters/PosixFileSystem.h index ee9335f..5f574d6 100644 --- a/include/microStore/Adapters/PosixFileSystem.h +++ b/include/microStore/Adapters/PosixFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #if defined(ESP32) #include @@ -198,7 +199,9 @@ class PosixFileSystem : public microStore::FileSystem { class FileSystemImpl : public microStore::FileSystemImpl { public: - FileSystemImpl(const char* basepath = "") : _basepath(basepath) {} + FileSystemImpl(const char* basepath = "") { + _basepath_ok = Path::copy_base(_basepath, sizeof(_basepath), basepath); + } virtual ~FileSystemImpl() {} public: @@ -218,9 +221,21 @@ class PosixFileSystem : public microStore::FileSystem { inline virtual bool init(bool reformatOnFail = true) override { USTORE_LOG("[ustore] Initializing PosixFileSystem\n"); + if (!_basepath_ok) { + USTORE_LOG("[ustore] PosixFileSystem base path longer than %d characters!\n", USTORE_MAX_BASEPATH_LEN - 1); + return false; + } #if defined(ESP32) - // Initialize LittleFS for POSIX file access - if (!LittleFS.begin(true, _basepath)) { + // Initialize LittleFS for POSIX file access. The base path is the + // mount point; Arduino keeps the pointer, so pass storage that + // outlives this adapter. + bool conflict = false; + const char* mount = Path::persistent_mount(_basepath, &conflict); + if (conflict) { + USTORE_LOG("[ustore] WARNING: LittleFS already mounted at \"%s\", ignoring base path \"%s\"\n", mount, _basepath); + Path::copy_base(_basepath, sizeof(_basepath), mount); + } + if (!LittleFS.begin(true, mount)) { USTORE_LOG("[ustore] Failed to initialize PosixFileSystem!\n"); return false; } @@ -247,7 +262,8 @@ class PosixFileSystem : public microStore::FileSystem { return true; } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); int flags; switch (mode) { // Read only. File must exist. ("r") @@ -283,7 +299,8 @@ class PosixFileSystem : public microStore::FileSystem { } - inline virtual bool exists(const char* path) override { + inline virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); int fd = ::open(path, O_RDONLY); if (fd != -1) { ::close(fd); @@ -292,15 +309,19 @@ class PosixFileSystem : public microStore::FileSystem { return false; } - inline virtual bool remove(const char* path) override { + inline virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return (::unlink(path) == 0); } - inline virtual bool rename(const char* from_path, const char* to_path) override { + inline virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return (::rename(from_path, to_path) == 0); } - inline virtual bool mkdir(const char* path) override { + inline virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); struct stat st = {0}; if (::stat(path, &st) == 0) { if (S_ISDIR(st.st_mode)) { @@ -314,12 +335,14 @@ class PosixFileSystem : public microStore::FileSystem { return (::mkdir(path, 0700) == 0); } - inline virtual bool rmdir(const char* path) override { + inline virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return (::rmdir(path) == 0); } - inline virtual size_t size(const char* path) override { + inline virtual size_t size(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, 0); struct stat st; if (stat(path, &st) != 0) { return 0; @@ -327,13 +350,15 @@ class PosixFileSystem : public microStore::FileSystem { return st.st_size; } - inline virtual bool isDirectory(const char* path) override { + inline virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); struct stat st = {0}; return (::stat(path, &st) == 0 && S_ISDIR(st.st_mode)); } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); DIR *dir = ::opendir(path); if (dir == NULL) { return files; @@ -369,7 +394,21 @@ class PosixFileSystem : public microStore::FileSystem { } private: - const char* _basepath = ""; + // Root for every path: the host directory on a native OS (empty means + // the current directory), or the LittleFS mount point on ESP32. + inline const char* resolve(const char* path, char* buf, size_t n) const { + if (!_basepath_ok) return nullptr; +#if defined(ESP32) + const char* resolved = Path::join(_basepath, path, buf, n); +#else + const char* resolved = Path::join(_basepath[0] ? _basepath : ".", path, buf, n); +#endif + if (resolved == nullptr) USTORE_LOG("[ustore] PosixFileSystem: path too long: %s\n", path ? path : "(null)"); + return resolved; + } + + char _basepath[USTORE_MAX_BASEPATH_LEN]; + bool _basepath_ok = false; }; }; diff --git a/include/microStore/Adapters/SDFileSystem.h b/include/microStore/Adapters/SDFileSystem.h index cae4f77..9a5eda4 100644 --- a/include/microStore/Adapters/SDFileSystem.h +++ b/include/microStore/Adapters/SDFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #include #include @@ -137,7 +138,8 @@ class SDFileSystem : public microStore::FileSystem { } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); const char* pmode; switch (mode) { // Read only. File must exist. ("r") @@ -180,28 +182,34 @@ class SDFileSystem : public microStore::FileSystem { } - virtual bool exists(const char* path) override { + virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return SD.exists(path); } - virtual bool remove(const char* path) override { + virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); // CBA Avoid logs like "remove(): does not exists or is directory" when file does not exist if (!exists(path)) return false; return SD.remove(path); } - virtual bool rename(const char* from_path, const char* to_path) override { + virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return SD.rename(from_path, to_path); } - virtual bool mkdir(const char* path) override { + virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!SD.mkdir(path)) { return false; } return true; } - virtual bool rmdir(const char* path) override { + virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!SD.rmdir(path)) { return false; } @@ -209,7 +217,8 @@ class SDFileSystem : public microStore::FileSystem { } - virtual bool isDirectory(const char* path) override { + virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); fs::File file = SD.open(path, FILE_READ); if (file) { bool is_directory = file.isDirectory(); @@ -219,8 +228,9 @@ class SDFileSystem : public microStore::FileSystem { return false; } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); fs::File root = SD.open(path); if (!root) { return files; @@ -253,6 +263,15 @@ class SDFileSystem : public microStore::FileSystem { int8_t _mosi = -1; int8_t _ss = -1; SPIClass _spi; + + private: + // Paths are root-relative ("/a/b", "./a/b" and "a/b" are the same file); + // the framework adds the mount point. + inline const char* resolve(const char* path, char* buf, size_t n) const { + const char* resolved = Path::normalize(path, buf, n); + if (resolved == nullptr) USTORE_LOG("[ustore] SDFileSystem: path too long: %s\n", path); + return resolved; + } }; }; diff --git a/include/microStore/Adapters/SPIFFSFileSystem.h b/include/microStore/Adapters/SPIFFSFileSystem.h index bce2b7e..b6ee84d 100644 --- a/include/microStore/Adapters/SPIFFSFileSystem.h +++ b/include/microStore/Adapters/SPIFFSFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #include @@ -129,7 +130,8 @@ class SPIFFSFileSystem : public microStore::FileSystem { } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); const char* pmode; switch (mode) { // Read only. File must exist. ("r") @@ -165,26 +167,32 @@ class SPIFFSFileSystem : public microStore::FileSystem { } - virtual bool exists(const char* path) override { + virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return SPIFFS.exists(path); } - virtual bool remove(const char* path) override { + virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return SPIFFS.remove(path); } - virtual bool rename(const char* from_path, const char* to_path) override { + virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return SPIFFS.rename(from_path, to_path); } - virtual bool mkdir(const char* path) override { + virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!SPIFFS.mkdir(path)) { return false; } return true; } - virtual bool rmdir(const char* path) override { + virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); if (!SPIFFS.rmdir(path)) { return false; } @@ -192,7 +200,8 @@ class SPIFFSFileSystem : public microStore::FileSystem { } - virtual bool isDirectory(const char* path) override { + virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); fs::File file = SPIFFS.open(path, FILE_READ); if (file) { bool is_directory = file.isDirectory(); @@ -202,8 +211,9 @@ class SPIFFSFileSystem : public microStore::FileSystem { return false; } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); fs::File root = SPIFFS.open(path); if (!root) { return files; @@ -229,6 +239,15 @@ class SPIFFSFileSystem : public microStore::FileSystem { return (SPIFFS.totalBytes() - SPIFFS.usedBytes()); } + + private: + // Paths are root-relative ("/a/b", "./a/b" and "a/b" are the same file); + // the framework adds the mount point. + inline const char* resolve(const char* path, char* buf, size_t n) const { + const char* resolved = Path::normalize(path, buf, n); + if (resolved == nullptr) USTORE_LOG("[ustore] SPIFFSFileSystem: path too long: %s\n", path); + return resolved; + } }; }; diff --git a/include/microStore/Adapters/StdioFileSystem.h b/include/microStore/Adapters/StdioFileSystem.h index 9e54e7d..25e1dcb 100644 --- a/include/microStore/Adapters/StdioFileSystem.h +++ b/include/microStore/Adapters/StdioFileSystem.h @@ -19,6 +19,7 @@ #include "../File.h" #include "../FileSystem.h" #include "../Log.h" +#include "../Path.h" #if defined(ESP32) #include @@ -187,7 +188,9 @@ class StdioFileSystem : public microStore::FileSystem { class FileSystemImpl : public microStore::FileSystemImpl { public: - FileSystemImpl(const char* basepath = "") : _basepath(basepath) {} + FileSystemImpl(const char* basepath = "") { + _basepath_ok = Path::copy_base(_basepath, sizeof(_basepath), basepath); + } virtual ~FileSystemImpl() {} public: @@ -207,9 +210,21 @@ class StdioFileSystem : public microStore::FileSystem { inline virtual bool init(bool reformatOnFail = true) override { USTORE_LOG("[ustore] Initializing StdioFileSystem\n"); + if (!_basepath_ok) { + USTORE_LOG("[ustore] StdioFileSystem base path longer than %d characters!\n", USTORE_MAX_BASEPATH_LEN - 1); + return false; + } #if defined(ESP32) - // Initialize LittleFS for POSIX file access - if (!LittleFS.begin(true, _basepath)) { + // Initialize LittleFS for POSIX file access. The base path is the + // mount point; Arduino keeps the pointer, so pass storage that + // outlives this adapter. + bool conflict = false; + const char* mount = Path::persistent_mount(_basepath, &conflict); + if (conflict) { + USTORE_LOG("[ustore] WARNING: LittleFS already mounted at \"%s\", ignoring base path \"%s\"\n", mount, _basepath); + Path::copy_base(_basepath, sizeof(_basepath), mount); + } + if (!LittleFS.begin(true, mount)) { USTORE_LOG("[ustore] Failed to initialize StdioFileSystem!\n"); return false; } @@ -236,7 +251,8 @@ class StdioFileSystem : public microStore::FileSystem { return true; } - virtual microStore::File open(const char* path, microStore::File::Mode mode, const bool create = false) override { + virtual microStore::File open(const char* path_in, microStore::File::Mode mode, const bool create = false) override { + USTORE_RESOLVE_PATH(path, path_in, {}); const char* pmode; switch (mode) { // Read only. File must exist. ("r") @@ -271,7 +287,8 @@ class StdioFileSystem : public microStore::FileSystem { } - inline virtual bool exists(const char* path) override { + inline virtual bool exists(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); FILE* file = ::fopen(path, "r"); if (file != nullptr) { ::fclose(file); @@ -280,20 +297,25 @@ class StdioFileSystem : public microStore::FileSystem { return false; } - inline virtual bool remove(const char* path) override { + inline virtual bool remove(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return (::remove(path) == 0); } - inline virtual bool rename(const char* from_path, const char* to_path) override { + inline virtual bool rename(const char* from_in, const char* to_in) override { + USTORE_RESOLVE_PATH(from_path, from_in, false); + USTORE_RESOLVE_PATH(to_path, to_in, false); return (::rename(from_path, to_path) == 0); } - inline virtual bool isDirectory(const char* path) override { + inline virtual bool isDirectory(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); struct stat st = {0}; return (::stat(path, &st) == 0 && S_ISDIR(st.st_mode)); } - inline virtual bool mkdir(const char* path) override { + inline virtual bool mkdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); struct stat st = {0}; if (::stat(path, &st) == 0) { if (S_ISDIR(st.st_mode)) return true; @@ -302,13 +324,15 @@ class StdioFileSystem : public microStore::FileSystem { return (::mkdir(path, 0700) == 0); } - inline virtual bool rmdir(const char* path) override { + inline virtual bool rmdir(const char* path_in) override { + USTORE_RESOLVE_PATH(path, path_in, false); return (::rmdir(path) == 0); } - virtual std::list listDirectory(const char* path, Callbacks::DirectoryListing callback = nullptr) override { + virtual std::list listDirectory(const char* path_in, Callbacks::DirectoryListing callback = nullptr) override { std::list files; + USTORE_RESOLVE_PATH(path, path_in, files); DIR *dir = ::opendir(path); if (dir == NULL) { return files; @@ -344,7 +368,21 @@ class StdioFileSystem : public microStore::FileSystem { } private: - const char* _basepath = ""; + // Root for every path: the host directory on a native OS (empty means + // the current directory), or the LittleFS mount point on ESP32. + inline const char* resolve(const char* path, char* buf, size_t n) const { + if (!_basepath_ok) return nullptr; +#if defined(ESP32) + const char* resolved = Path::join(_basepath, path, buf, n); +#else + const char* resolved = Path::join(_basepath[0] ? _basepath : ".", path, buf, n); +#endif + if (resolved == nullptr) USTORE_LOG("[ustore] StdioFileSystem: path too long: %s\n", path ? path : "(null)"); + return resolved; + } + + char _basepath[USTORE_MAX_BASEPATH_LEN]; + bool _basepath_ok = false; }; }; diff --git a/include/microStore/Path.h b/include/microStore/Path.h new file mode 100644 index 0000000..c04318e --- /dev/null +++ b/include/microStore/Path.h @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +/* + * Path resolution shared by all FileSystem adapters. + * + * Path model (identical on every adapter and platform): + * - Every adapter has a root. A path is always relative to that root, so + * "/a/b", "./a/b" and "a/b" all name the same file: /a/b. + * - An adapter's base path says where the root is. An empty base path + * selects the backend's default root (the current directory on a host + * OS, the mounted filesystem's root on an MCU). + * - ".." is passed through untouched; this is not a sandbox. + * + * Everything here works on caller-supplied buffers: no heap allocation. + */ + +#include +#include + +// Max length (including terminator) of a fully resolved path, base included. +// Each adapter call places one buffer of this size on the stack (two for rename). +#ifndef USTORE_MAX_PATH_LEN +#define USTORE_MAX_PATH_LEN 128 +#endif + +// Max length (including terminator) of an adapter base path / mount point. +#ifndef USTORE_MAX_BASEPATH_LEN +#define USTORE_MAX_BASEPATH_LEN 32 +#endif + +namespace microStore { namespace Path { + +// Skip everything that only says "the root": any run of "/" and "./" +// components, and a lone trailing ".". Returns the root-relative remainder. +inline const char* skip_root(const char* p) { + for (;;) { + if (p[0] == '/') { ++p; continue; } + if (p[0] == '.' && p[1] == '/') { p += 2; continue; } + if (p[0] == '.' && p[1] == '\0') { ++p; continue; } + return p; + } +} + +// Append "/" + rel to buf at pos, collapsing runs of "/" in rel. A trailing +// "/" in rel is kept (FileStore uses it to mark a directory prefix). +// Returns false if the result (plus terminator) does not fit in n. +inline bool append_rel(char* buf, size_t n, size_t pos, const char* rel) { + if (pos + 1 >= n) return false; + buf[pos++] = '/'; + for (const char* s = rel; *s; ++s) { + if (*s == '/' && buf[pos - 1] == '/') continue; + if (pos + 1 >= n) return false; + buf[pos++] = *s; + } + buf[pos] = '\0'; + return true; +} + +// True if p is already "/" followed by a remainder with no root-only prefix +// and no repeated "/", so it can be used without copying. +inline bool is_canonical(const char* p) { + if (p[0] != '/' || skip_root(p) != p + 1) return false; + return strstr(p, "//") == nullptr; +} + +// Root-relative path as "/a/b". Returns p itself when already canonical, +// otherwise writes into buf. Null or empty means the root ("/"). +// Returns nullptr if buf is too small. +inline const char* normalize(const char* p, char* buf, size_t n) { + if (p == nullptr || p[0] == '\0') return "/"; + if (is_canonical(p)) return p; + if (!append_rel(buf, n, 0, skip_root(p))) return nullptr; + return buf; +} + +// base + normalized p, written into buf. A trailing "/" on base is ignored, +// so a base of "/" yields "/a/b". Returns nullptr if buf is too small. +inline const char* join(const char* base, const char* p, char* buf, size_t n) { + size_t bl = base ? strlen(base) : 0; + while (bl > 0 && base[bl - 1] == '/') --bl; + if (bl >= n) return nullptr; + if (bl > 0) memcpy(buf, base, bl); + if (!append_rel(buf, n, bl, (p == nullptr) ? "" : skip_root(p))) return nullptr; + return buf; +} + +// Copy a caller's base path into an adapter-owned buffer, so the adapter never +// keeps the caller's pointer. Trailing "/" are dropped, except that "/" itself +// is kept (it means the host root, which differs from an empty base). +// Null copies as empty. Returns false (leaving dst empty) if src does not fit. +inline bool copy_base(char* dst, size_t n, const char* src) { + dst[0] = '\0'; + if (src == nullptr) return true; + size_t len = strlen(src); + while (len > 1 && src[len - 1] == '/') --len; + if (len >= n) return false; + memcpy(dst, src, len); + dst[len] = '\0'; + return true; +} + +// Mount point for a base path, held in storage that lives for the whole +// program. Arduino's FS layer keeps the raw pointer passed to begin(), so the +// string must outlive any adapter. One buffer is shared by every adapter that +// mounts the (singleton) LittleFS. The first call fixes the mount point; a later +// call with a different base keeps the original, since the filesystem is +// already mounted there, and reports the conflict through *conflict. +// "/" maps to "" (ESP-IDF's fallback mount, i.e. the whole filesystem). +inline const char* persistent_mount(const char* base, bool* conflict = nullptr) { + static char mount[USTORE_MAX_BASEPATH_LEN]; + static bool set = false; + const char* want = (base == nullptr || strcmp(base, "/") == 0) ? "" : base; + if (!set) { + if (!copy_base(mount, sizeof(mount), want)) return nullptr; + set = true; + } + if (conflict) *conflict = (strcmp(mount, want) != 0); + return mount; +} + +} } + +// Adapter helper: resolve `in` through the adapter's resolve() into a stack +// buffer named after `var`, and return `fail` from the enclosing function if it +// does not fit. A too-long path fails the operation rather than being +// truncated, so it can never touch a different file. +#define USTORE_RESOLVE_PATH(var, in, fail) \ + char var##_buf[USTORE_MAX_PATH_LEN]; \ + const char* var = resolve((in), var##_buf, sizeof(var##_buf)); \ + if (var == nullptr) return fail diff --git a/test/test_path/test_path.cpp b/test/test_path/test_path.cpp new file mode 100644 index 0000000..6d29f22 --- /dev/null +++ b/test/test_path/test_path.cpp @@ -0,0 +1,163 @@ +/* + * Unit tests for microStore::Path, the allocation-free path resolution shared + * by all FileSystem adapters. + */ + +#include +#include + +#include + +using namespace microStore; + +static char buf[USTORE_MAX_PATH_LEN]; + +static void expect_norm(const char* in, const char* want) { + const char* got = Path::normalize(in, buf, sizeof(buf)); + TEST_ASSERT_NOT_NULL_MESSAGE(got, in ? in : "(null)"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(want, got, in ? in : "(null)"); +} + +static void expect_join(const char* base, const char* in, const char* want) { + const char* got = Path::join(base, in, buf, sizeof(buf)); + TEST_ASSERT_NOT_NULL_MESSAGE(got, in ? in : "(null)"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(want, got, in ? in : "(null)"); +} + +void test_normalize_spellings_are_equivalent() { + expect_norm("x", "/x"); + expect_norm("./x", "/x"); + expect_norm("/x", "/x"); + expect_norm("//x", "/x"); + expect_norm("././x", "/x"); + expect_norm(".//x", "/x"); + expect_norm("a//b///c", "/a/b/c"); + expect_norm("./kv/seg0.dat", "/kv/seg0.dat"); +} + +void test_normalize_root() { + expect_norm(nullptr, "/"); + expect_norm("", "/"); + expect_norm(".", "/"); + expect_norm("./", "/"); + expect_norm("/", "/"); + expect_norm("//", "/"); +} + +void test_normalize_keeps_trailing_slash() { + expect_norm("./kv/", "/kv/"); + expect_norm("kv//", "/kv/"); +} + +void test_normalize_does_not_interpret_dotdot() { + expect_norm("../x", "/../x"); + expect_norm("a/./b", "/a/./b"); + expect_norm(".hidden", "/.hidden"); +} + +void test_normalize_canonical_is_not_copied() { + const char* in = "/kv/seg0.dat"; + TEST_ASSERT_EQUAL_PTR(in, Path::normalize(in, buf, sizeof(buf))); + const char* rel = "./kv/seg0.dat"; + TEST_ASSERT_EQUAL_PTR(buf, Path::normalize(rel, buf, sizeof(buf))); +} + +void test_normalize_overflow() { + char small[8]; + TEST_ASSERT_NOT_NULL(Path::normalize("abcdef", small, sizeof(small))); // "/abcdef" + NUL = 8 + TEST_ASSERT_EQUAL_STRING("/abcdef", small); + TEST_ASSERT_NULL(Path::normalize("abcdefg", small, sizeof(small))); + // Exactly at USTORE_MAX_PATH_LEN. + std::string fits(USTORE_MAX_PATH_LEN - 2, 'f'); + TEST_ASSERT_NOT_NULL(Path::normalize(fits.c_str(), buf, sizeof(buf))); + std::string over(USTORE_MAX_PATH_LEN - 1, 'o'); + TEST_ASSERT_NULL(Path::normalize(over.c_str(), buf, sizeof(buf))); +} + +void test_join() { + expect_join(".", "./x", "./x"); + expect_join(".", "/x", "./x"); + expect_join(".", "x", "./x"); + expect_join(".", "./", "./"); + expect_join(".", nullptr, "./"); + expect_join("/", "/tmp/x", "/tmp/x"); + expect_join("/", "./x", "/x"); + expect_join("/var/app", "/kv/", "/var/app/kv/"); + expect_join("/var/app/", "kv", "/var/app/kv"); + expect_join("/var/app//", "//kv", "/var/app/kv"); + expect_join("", "./x", "/x"); + expect_join(nullptr, "x", "/x"); + expect_join("data", "x", "data/x"); +} + +void test_join_overflow() { + char small[8]; + TEST_ASSERT_NOT_NULL(Path::join("/ab", "cd", small, sizeof(small))); // "/ab/cd" + NUL = 7 + TEST_ASSERT_NULL(Path::join("/abcd", "efg", small, sizeof(small))); + TEST_ASSERT_NULL(Path::join("/abcdefgh", "x", small, sizeof(small))); +} + +void test_copy_base() { + char dst[USTORE_MAX_BASEPATH_LEN]; + TEST_ASSERT_TRUE(Path::copy_base(dst, sizeof(dst), nullptr)); + TEST_ASSERT_EQUAL_STRING("", dst); + TEST_ASSERT_TRUE(Path::copy_base(dst, sizeof(dst), "/")); + TEST_ASSERT_EQUAL_STRING("/", dst); + TEST_ASSERT_TRUE(Path::copy_base(dst, sizeof(dst), "//")); + TEST_ASSERT_EQUAL_STRING("/", dst); + TEST_ASSERT_TRUE(Path::copy_base(dst, sizeof(dst), "/littlefs/")); + TEST_ASSERT_EQUAL_STRING("/littlefs", dst); + std::string fits(USTORE_MAX_BASEPATH_LEN - 1, 'b'); + TEST_ASSERT_TRUE(Path::copy_base(dst, sizeof(dst), fits.c_str())); + std::string over(USTORE_MAX_BASEPATH_LEN, 'b'); + TEST_ASSERT_FALSE(Path::copy_base(dst, sizeof(dst), over.c_str())); + TEST_ASSERT_EQUAL_STRING("", dst); +} + +void test_persistent_mount() { + bool conflict = true; + const char* m = Path::persistent_mount("/littlefs", &conflict); + TEST_ASSERT_EQUAL_STRING("/littlefs", m); + TEST_ASSERT_FALSE(conflict); + // Same storage every time; a different base keeps the first mount. + TEST_ASSERT_EQUAL_PTR(m, Path::persistent_mount("/littlefs", &conflict)); + TEST_ASSERT_FALSE(conflict); + TEST_ASSERT_EQUAL_PTR(m, Path::persistent_mount("/other", &conflict)); + TEST_ASSERT_TRUE(conflict); + TEST_ASSERT_EQUAL_STRING("/littlefs", m); +} + +void setUp() {} +void tearDown() {} + +int runUnityTests(void) { + UNITY_BEGIN(); + RUN_TEST(test_normalize_spellings_are_equivalent); + RUN_TEST(test_normalize_root); + RUN_TEST(test_normalize_keeps_trailing_slash); + RUN_TEST(test_normalize_does_not_interpret_dotdot); + RUN_TEST(test_normalize_canonical_is_not_copied); + RUN_TEST(test_normalize_overflow); + RUN_TEST(test_join); + RUN_TEST(test_join_overflow); + RUN_TEST(test_copy_base); + RUN_TEST(test_persistent_mount); + return UNITY_END(); +} + +// For native dev-platform or for some embedded frameworks +int main(void) { + return runUnityTests(); +} + +#ifdef ARDUINO +// For Arduino framework +void setup() { + // Wait ~2 seconds before the Unity test runner + // establishes connection with a board Serial interface + delay(2000); + + runUnityTests(); +} +void loop() {} +#endif diff --git a/test/test_posix_fs/test_posix_fs.cpp b/test/test_posix_fs/test_posix_fs.cpp index 68d2bff..61f3692 100644 --- a/test/test_posix_fs/test_posix_fs.cpp +++ b/test/test_posix_fs/test_posix_fs.cpp @@ -1,27 +1,55 @@ /* * Tests for the POSIX and stdio filesystem adapters against a real - * temporary directory. + * temporary directory, including base-path / relative-path resolution. + * + * Path model: every path is relative to the adapter's root ("/a", "./a" and + * "a" are the same file); the base path says where the root is, and an empty + * base means the current directory. */ #include #include #include +#include #include #include #include #include +using microStore::FileSystem; +using microStore::Adapters::PosixFileSystem; +using microStore::Adapters::StdioFileSystem; + static std::string tmp; +static char saved_cwd[1024]; + +typedef FileSystem (*MakeFs)(const char* base); +static FileSystem make_posix(const char* base) { return PosixFileSystem(base); } +static FileSystem make_stdio(const char* base) { return StdioFileSystem(base); } +static FileSystem make_posix_default(const char*) { return PosixFileSystem(); } +static FileSystem make_stdio_default(const char*) { return StdioFileSystem(); } static bool is_dir(const std::string& path) { struct stat st; return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); } -/* ---- rmdir ---- */ +static bool is_file(const std::string& path) { + return ::access(path.c_str(), F_OK) == 0 && !is_dir(path); +} + +static void write_file(FileSystem& fs, const char* path) { + microStore::File f = fs.open(path, microStore::File::ModeWrite, true); + TEST_ASSERT_TRUE_MESSAGE((bool)f, path); + TEST_ASSERT_EQUAL(4, f.write("test", 4)); + f.close(); +} -static void check_rmdir(microStore::FileSystem fs) { +/* ---- rmdir (base "/" gives the host root, so absolute host paths work) ---- */ + +static void check_rmdir(MakeFs make) { + FileSystem fs = make("/"); const std::string dir = tmp + "/sub"; TEST_ASSERT_TRUE(fs.mkdir(dir.c_str())); TEST_ASSERT_TRUE(fs.rmdir(dir.c_str())); @@ -29,12 +57,130 @@ static void check_rmdir(microStore::FileSystem fs) { TEST_ASSERT_FALSE(fs.rmdir(dir.c_str())); } -void test_posix_rmdir_reports_result() { - check_rmdir(microStore::Adapters::PosixFileSystem()); +void test_posix_rmdir_reports_result() { check_rmdir(make_posix); } +void test_stdio_rmdir_reports_result() { check_rmdir(make_stdio); } + +/* ---- Every spelling of a path lands under the base, through every method ---- */ + +static void check_paths_under_base(MakeFs make, const char* dir, const char* file, const char* moved) { + FileSystem fs = make(tmp.c_str()); + TEST_ASSERT_TRUE(fs.mkdir(dir)); + TEST_ASSERT_TRUE(is_dir(tmp + "/kv")); + TEST_ASSERT_TRUE(fs.isDirectory(dir)); + + write_file(fs, file); + TEST_ASSERT_TRUE(is_file(tmp + "/kv/a")); + TEST_ASSERT_TRUE(fs.exists(file)); + TEST_ASSERT_EQUAL(4, fs.size(file)); + TEST_ASSERT_EQUAL(1, fs.listDirectory(dir).size()); + + TEST_ASSERT_TRUE(fs.rename(file, moved)); + TEST_ASSERT_TRUE(is_file(tmp + "/kv/b")); + TEST_ASSERT_FALSE(is_file(tmp + "/kv/a")); + TEST_ASSERT_TRUE(fs.remove(moved)); + TEST_ASSERT_FALSE(fs.exists(moved)); + TEST_ASSERT_TRUE(fs.rmdir(dir)); + TEST_ASSERT_FALSE(is_dir(tmp + "/kv")); +} + +static void check_all_spellings(MakeFs make) { + check_paths_under_base(make, "/kv", "/kv/a", "/kv/b"); + check_paths_under_base(make, "./kv/", "./kv/a", "./kv/b"); + check_paths_under_base(make, "kv", "kv/a", "kv//b"); } -void test_stdio_rmdir_reports_result() { - check_rmdir(microStore::Adapters::StdioFileSystem()); +void test_posix_paths_resolve_under_base() { check_all_spellings(make_posix); } +void test_stdio_paths_resolve_under_base() { check_all_spellings(make_stdio); } + +/* ---- Empty (default) base: root is the current directory ---- */ + +static void check_default_base_is_cwd(MakeFs make) { + TEST_ASSERT_EQUAL(0, ::chdir(tmp.c_str())); + FileSystem fs = make(""); + write_file(fs, "./rel"); + write_file(fs, "/abs"); + write_file(fs, "bare"); + TEST_ASSERT_EQUAL(0, ::chdir(saved_cwd)); + TEST_ASSERT_TRUE(is_file(tmp + "/rel")); + TEST_ASSERT_TRUE(is_file(tmp + "/abs")); + TEST_ASSERT_TRUE(is_file(tmp + "/bare")); +} + +void test_posix_default_base_is_cwd() { check_default_base_is_cwd(make_posix_default); } +void test_stdio_default_base_is_cwd() { check_default_base_is_cwd(make_stdio_default); } + +/* ---- Base "/" reaches the host root (and a trailing "/" on a base is ignored) ---- */ + +static void check_host_root(MakeFs make) { + FileSystem root = make("/"); + const std::string abs = tmp + "/host_abs"; + write_file(root, abs.c_str()); + TEST_ASSERT_TRUE(is_file(abs)); + TEST_ASSERT_TRUE(root.exists(abs.c_str())); + + const std::string slashed = tmp + "/"; + FileSystem fs = make(slashed.c_str()); + write_file(fs, "trailing"); + TEST_ASSERT_TRUE(is_file(tmp + "/trailing")); +} + +void test_posix_host_root() { check_host_root(make_posix); } +void test_stdio_host_root() { check_host_root(make_stdio); } + +/* ---- The caller's base string is copied, never kept ---- */ + +static void check_base_copied(MakeFs make) { + std::string* base = new std::string(tmp); + FileSystem fs = make(base->c_str()); + base->assign(base->size(), 'x'); + delete base; + write_file(fs, "./copied"); + TEST_ASSERT_TRUE(is_file(tmp + "/copied")); +} + +void test_posix_base_copied() { check_base_copied(make_posix); } +void test_stdio_base_copied() { check_base_copied(make_stdio); } + +/* ---- Limits: too-long base fails init and every op; too-long path fails the op ---- */ + +static void check_limits(MakeFs make) { + std::string long_base(USTORE_MAX_BASEPATH_LEN, 'b'); + FileSystem bad = make(long_base.c_str()); + TEST_ASSERT_FALSE(bad.init(false)); + TEST_ASSERT_FALSE(bad.exists("x")); + TEST_ASSERT_FALSE((bool)bad.open("x", microStore::File::ModeWrite, true)); + + FileSystem fs = make(tmp.c_str()); + // Resolved length is tmp + "/" + name; make it exactly one too many. + std::string name(USTORE_MAX_PATH_LEN - tmp.size() - 1, 'n'); + TEST_ASSERT_FALSE((bool)fs.open(name.c_str(), microStore::File::ModeWrite, true)); + name.pop_back(); + write_file(fs, name.c_str()); + TEST_ASSERT_TRUE(is_file(tmp + "/" + name)); +} + +void test_posix_limits() { check_limits(make_posix); } +void test_stdio_limits() { check_limits(make_stdio); } + +/* ---- init() write check and a FileStore both live under the base ---- */ + +static void check_store_under_base(MakeFs make, const char* prefix) { + FileSystem fs = make(tmp.c_str()); + TEST_ASSERT_TRUE(fs.init(true)); + TEST_ASSERT_FALSE(is_file(tmp + "/__init_test__")); + microStore::FileStore store; + TEST_ASSERT_TRUE(store.init(fs, prefix)); + TEST_ASSERT_TRUE(store.put("k", (const uint8_t*)"v", 1)); + store.close(); + TEST_ASSERT_TRUE(is_dir(tmp + "/kv")); + TEST_ASSERT_TRUE(is_file(tmp + "/kv/seg0.dat")); +} + +void test_posix_store_under_base() { + check_store_under_base(make_posix, "/kv/"); +} +void test_stdio_store_under_base() { + check_store_under_base(make_stdio, "./kv/"); } /* ---- Main ---- */ @@ -46,14 +192,28 @@ void setUp() { } void tearDown() { + (void)::chdir(saved_cwd); std::string cmd = "rm -rf '" + tmp + "'"; (void)std::system(cmd.c_str()); } int runUnityTests(void) { + TEST_ASSERT_NOT_NULL(::getcwd(saved_cwd, sizeof(saved_cwd))); UNITY_BEGIN(); RUN_TEST(test_posix_rmdir_reports_result); RUN_TEST(test_stdio_rmdir_reports_result); + RUN_TEST(test_posix_paths_resolve_under_base); + RUN_TEST(test_stdio_paths_resolve_under_base); + RUN_TEST(test_posix_default_base_is_cwd); + RUN_TEST(test_stdio_default_base_is_cwd); + RUN_TEST(test_posix_host_root); + RUN_TEST(test_stdio_host_root); + RUN_TEST(test_posix_base_copied); + RUN_TEST(test_stdio_base_copied); + RUN_TEST(test_posix_limits); + RUN_TEST(test_stdio_limits); + RUN_TEST(test_posix_store_under_base); + RUN_TEST(test_stdio_store_under_base); return UNITY_END(); }