diff --git a/CMakeLists.txt b/CMakeLists.txt index f783871..431b569 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,3 +92,4 @@ enable_testing() add_subdirectory(src/core) add_subdirectory(src/app) add_subdirectory(src/shell/instrument) +add_subdirectory(src/shell/package) diff --git a/src/shell/package/CLAUDE.md b/src/shell/package/CLAUDE.md new file mode 100644 index 0000000..6456fe9 --- /dev/null +++ b/src/shell/package/CLAUDE.md @@ -0,0 +1,61 @@ +# src/shell/package — package filesystem + dialog seam + +## Scope + +The filesystem and dialog acts behind bank-package export/import: streaming package +file I/O (`package_io`), the landed-file journal and its rollback delete +(`package_rollback`), and the platform file pickers (`package_pickers`). This seam +is bytes-only — the package format (magic, manifest, entry layout) is +`core/package`'s business, and the export/import verbs that orchestrate both do not +live here yet. No REAPER project state is touched in this directory: no ext-state +read or write, no undo block, no generation bump — those belong to the verbs. + +## Invariants + +- **Atomic write.** A package accumulates in a `.rsbanktmp` sibling in the + destination directory and reaches the destination only through `commit()`'s + rename (the mono-collapse temp+rename precedent). A failed, aborted, or abandoned + write leaves the destination absent or holding its prior contents — never a + partial `.rsbank`. +- **Streaming, both ways — at most ONE entry's payload in memory.** Writes append + one payload at a time; reads seek and materialize one range at a time. The claim + is structural, not aspirational: every payload crosses this seam as a move-only + `PayloadBuffer`, and `PayloadBuffer::alive()` is the seam counter the tests + assert against. There is no read-whole-package or write-whole-package entry + point; do not add one. +- **The rollback delete is prune's ONE carve-out, cited not restated.** The + citation and the discriminator live at `package_rollback.cpp`'s header. The + journal makes the discriminator structural: only paths its own `writeLandedFile` + successfully created are recorded, and `rollback()` consumes only the record — a + path this import did not write cannot be handed to it. +- **No overwrite of a bank-folder file, ever.** `writeLandedFile` refuses an + existing destination outright; collision handling (auto-rename) is the import + plan's job upstream. The package writer itself DOES replace an existing + destination — the export save dialog's own overwrite confirm is the consent — + and that asymmetry is deliberate. +- **The two pickers are asymmetric, and the asymmetry is real.** Import rides + REAPER's own `GetUserFileNameForRead` (both platforms); export goes native — + Win32 `GetSaveFileNameW` / SWELL `BrowseForSaveFile` — because the always-present + REAPER surface offers no save picker. Do not symmetrize; the newer + `GetUserFileName(mode=0)` alternative and why it is not used are recorded in + `package_pickers.cpp`'s header. + +## Modules + +- `package_io` — the streaming filesystem seam: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), and `listFolderFileNames` (bare names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW. +- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (temp+rename land, recorded on success only, refuses an existing destination and an empty payload) and `rollback` (deletes exactly the recorded set, hard unlink — nothing ever referenced these bytes — tolerating a vanished file). REAPER-free; tested without a DAW. +- `package_pickers` — the two pickers in one platform TU (`#ifdef _WIN32` / `#else swell/swell.h`, the `draw_kit`/`prune_fs` split): `pickPackageForImport` (REAPER read picker) and `pickPackageSavePath` (native save dialog, UTF-8 in/out on Windows). Compile-only until the verbs land; nothing here can be exercised in a unit test. + +## Gotchas + +- A crash mid-write strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no + picker filter matches it), and a later export to the same destination truncates + it — but one stranded in the BANK folder by a mid-import crash is a foreign file + to prune (not owned, so never an orphan) until removed by hand. `[verify — DAW]` + whether the import verb should pre-clean stale `.rsbanktmp` names when it lands. +- The picker `defext`/filter strings are spelled to the Win32 `lpstrDefExt` + convention (no dot) but are `[verify — DAW]` on all three platforms — neither + picker is exercised outside a live REAPER session. +- `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by + design (the one "nothing to work with" branch). A genuinely zero-length entry + cannot round-trip through this seam; the format layer must not emit one. diff --git a/src/shell/package/CMakeLists.txt b/src/shell/package/CMakeLists.txt new file mode 100644 index 0000000..ad716e4 --- /dev/null +++ b/src/shell/package/CMakeLists.txt @@ -0,0 +1,20 @@ +# The filesystem + dialog seam for bank packages. package_io / package_rollback are +# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and +# their tests run without a DAW. The export/import verbs that drive all three targets +# are not in this directory yet. + +reasampler_pure_library(package_io SOURCES package_io.cpp LINK PUBLIC file_bytes) +reasampler_test(package_io LINK package_io) + +reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io) +reasampler_test(package_rollback LINK package_rollback) + +# The platform pickers touch the REAPER API and the native save dialog, so no test +# target can exercise them; declared as a library so both picker paths stay compiled. +add_library(package_pickers STATIC package_pickers.cpp) +target_include_directories(package_pickers PUBLIC ${REASAMPLER_SRC_DIR}) +target_include_directories(package_pickers PRIVATE ${SDK_INC} ${WDL_INC}) +if(NOT WIN32) + # Match the loadable modules: SWELL is provided by the host REAPER at runtime. + target_compile_definitions(package_pickers PRIVATE SWELL_PROVIDED_BY_APP) +endif() diff --git a/src/shell/package/package_io.cpp b/src/shell/package/package_io.cpp new file mode 100644 index 0000000..92f645b --- /dev/null +++ b/src/shell/package/package_io.cpp @@ -0,0 +1,170 @@ +// package_io.cpp — see package_io.h for the seam's contract. Non-throwing at the +// boundary: every filesystem call uses the error_code form so no filesystem_error +// crosses into a REAPER action body. + +#include "shell/package/package_io.h" + +#include +#include +#include +#include + +#include "core/util/file_bytes.h" + +namespace reasampler { + +namespace fs = std::filesystem; + +namespace { +std::atomic g_alivePayloads{0}; +} + +// --------------------------------------------------------------------------- +// PayloadBuffer + +PayloadBuffer::PayloadBuffer(std::vector bytes) + : bytes_(std::move(bytes)), counted_(!bytes_.empty()) { + if (counted_) g_alivePayloads.fetch_add(1, std::memory_order_relaxed); +} + +PayloadBuffer::~PayloadBuffer() { release(); } + +PayloadBuffer::PayloadBuffer(PayloadBuffer&& other) noexcept + : bytes_(std::move(other.bytes_)), counted_(other.counted_) { + // The count transfers with the bytes — a move must never double-count. + other.bytes_.clear(); + other.counted_ = false; +} + +PayloadBuffer& PayloadBuffer::operator=(PayloadBuffer&& other) noexcept { + if (this != &other) { + release(); + bytes_ = std::move(other.bytes_); + counted_ = other.counted_; + other.bytes_.clear(); + other.counted_ = false; + } + return *this; +} + +int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); } + +void PayloadBuffer::release() { + if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed); + counted_ = false; + bytes_.clear(); +} + +// --------------------------------------------------------------------------- +// PackageFileWriter + +PackageFileWriter::PackageFileWriter(std::string destAbsPath) + : destPath_(std::move(destAbsPath)), tempPath_(destPath_ + ".rsbanktmp") { + out_.open(tempPath_, std::ios::binary | std::ios::trunc); + ok_ = static_cast(out_); +} + +PackageFileWriter::~PackageFileWriter() { + if (!done_) abort(); +} + +bool PackageFileWriter::appendRaw(const std::uint8_t* data, std::size_t len) { + if (!ok_ || done_) return false; + if (len == 0) return true; + out_.write(reinterpret_cast(data), + static_cast(len)); + ok_ = static_cast(out_); + return ok_; +} + +bool PackageFileWriter::appendPayload(const PayloadBuffer& payload) { + return appendRaw(payload.data(), payload.size()); +} + +bool PackageFileWriter::commit() { + if (done_) return false; + if (ok_) { + out_.flush(); + ok_ = static_cast(out_); + } + out_.close(); + if (!ok_) { + abort(); + return false; + } + // rename() replaces the destination in one step (the mono-collapse precedent): + // prior contents survive until the replacement is known-complete, and a failed + // rename self-cleans the temp rather than littering it. + std::error_code ec; + fs::rename(tempPath_, destPath_, ec); + if (ec) { + fs::remove(tempPath_, ec); + done_ = true; + ok_ = false; + return false; + } + done_ = true; + return true; +} + +void PackageFileWriter::abort() { + if (done_) return; + out_.close(); + std::error_code ec; + fs::remove(tempPath_, ec); + done_ = true; + ok_ = false; +} + +// --------------------------------------------------------------------------- +// PackageFileReader + +PackageFileReader::PackageFileReader(const std::string& srcAbsPath) { + std::error_code ec; + const std::uintmax_t sz = fs::file_size(srcAbsPath, ec); + if (ec) return; + in_.open(srcAbsPath, std::ios::binary); + if (!in_) return; + size_ = static_cast(sz); + ok_ = true; +} + +PayloadBuffer PackageFileReader::readRange(std::uint64_t offset, std::uint64_t length) { + // Overflow-safe range check: length is capped by the real file size before any + // allocation happens, so a hostile offset/length pair cannot demand the moon. + if (!ok_ || length == 0 || length > size_ || offset > size_ - length) { + return PayloadBuffer{}; + } + in_.clear(); // a prior failed read must not poison this one + in_.seekg(static_cast(offset)); + if (!in_) return PayloadBuffer{}; + std::vector bytes(static_cast(length)); + in_.read(reinterpret_cast(bytes.data()), + static_cast(length)); + if (static_cast(in_.gcount()) != length) return PayloadBuffer{}; + return PayloadBuffer(std::move(bytes)); +} + +// --------------------------------------------------------------------------- + +PayloadBuffer readFilePayload(const std::string& absPath) { + return PayloadBuffer(util::readFileBytes(absPath)); +} + +std::vector listFolderFileNames(const std::string& dirAbsPath) { + std::vector names; + std::error_code ec; + // Manual iterator form (it.increment(ec)) keeps the loop non-throwing on a + // mid-iteration failure, matching prune_fs's enumerate. + fs::directory_iterator it(dirAbsPath, ec); + for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { + const auto& entry = *it; + std::error_code reg_ec; + if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials + names.push_back(entry.path().filename().string()); + } + std::sort(names.begin(), names.end()); + return names; +} + +} // namespace reasampler diff --git a/src/shell/package/package_io.h b/src/shell/package/package_io.h new file mode 100644 index 0000000..6f36d2d --- /dev/null +++ b/src/shell/package/package_io.h @@ -0,0 +1,112 @@ +// shell/package/package_io — streaming filesystem seam for bank packages: append one +// payload at a time through a temp-file + atomic-rename writer, seek and read one +// payload at a time back out. Bytes only: what a package contains is core/package's +// business, never this seam's. Blocking I/O — UI-thread actions only, never the +// audio thread. + +#pragma once + +#include +#include +#include +#include + +namespace reasampler { + +// One entry's payload, and the seam counter that makes "never more than one entry in +// memory" assertable: alive() counts every buffer currently holding bytes, so the +// streaming claim is a test CHECK against this counter rather than a memory +// measurement. Move-only — copying a payload would silently double the held bytes. +class PayloadBuffer { +public: + PayloadBuffer() = default; + explicit PayloadBuffer(std::vector bytes); + ~PayloadBuffer(); + PayloadBuffer(PayloadBuffer&& other) noexcept; + PayloadBuffer& operator=(PayloadBuffer&& other) noexcept; + PayloadBuffer(const PayloadBuffer&) = delete; + PayloadBuffer& operator=(const PayloadBuffer&) = delete; + + const std::uint8_t* data() const { return bytes_.data(); } + std::size_t size() const { return bytes_.size(); } + bool empty() const { return bytes_.empty(); } + const std::vector& bytes() const { return bytes_; } + + // Buffers currently holding at least one byte, process-wide. + static int alive(); + +private: + void release(); + + std::vector bytes_; + bool counted_ = false; +}; + +// Streaming atomic writer. Bytes accumulate in ".rsbanktmp" beside the +// destination (same directory, so the final rename never crosses a volume); the +// destination itself is touched only by commit()'s rename, so a failed, aborted, or +// abandoned write leaves it absent or holding its prior contents — never a partial +// file. Destruction without commit() aborts and removes the temp. commit() REPLACES +// an existing destination: the export save dialog's own overwrite confirm is the +// consent (the never-overwrite rule for bank-folder files lives in +// LandedFileJournal, upstream of this writer). Neither copyable nor movable, and +// append-only — there is deliberately no way to hand it a whole package at once. +class PackageFileWriter { +public: + explicit PackageFileWriter(std::string destAbsPath); + ~PackageFileWriter(); + PackageFileWriter(const PackageFileWriter&) = delete; + PackageFileWriter& operator=(const PackageFileWriter&) = delete; + + bool ok() const { return ok_; } + // Framing/header bytes. False on a failed or already-finished writer. + bool appendRaw(const std::uint8_t* data, std::size_t len); + // One entry's bytes. Same contract as appendRaw. + bool appendPayload(const PayloadBuffer& payload); + // Flush, close, rename over the destination. False (and self-cleaning: the temp + // is removed, the destination untouched) on any failure or on a second call. + bool commit(); + // Close and remove the temp; the destination is never touched. Idempotent. + void abort(); + + const std::string& destPath() const { return destPath_; } + const std::string& tempPath() const { return tempPath_; } + +private: + std::string destPath_; + std::string tempPath_; + std::ofstream out_; + bool ok_ = false; + bool done_ = false; +}; + +// Seek-and-read reader: exactly one payload is materialized per readRange call, and +// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure +// — unopenable file, zero length, out of range, short read — so the caller has one +// "nothing to work with" branch (file_bytes' contract). +class PackageFileReader { +public: + explicit PackageFileReader(const std::string& srcAbsPath); + + bool ok() const { return ok_; } + std::uint64_t fileSize() const { return size_; } + // Bytes [offset, offset+length). Range-checked against the real file size, so a + // hostile layout can never demand an allocation past the file's end. + PayloadBuffer readRange(std::uint64_t offset, std::uint64_t length); + +private: + std::ifstream in_; + std::uint64_t size_ = 0; + bool ok_ = false; +}; + +// One source file read whole as one entry's payload — a bank file IS the streaming +// unit, so whole-file here is one entry, released before the next is read. Empty on +// any failure, per readFileBytes. +PayloadBuffer readFilePayload(const std::string& absPath); + +// Bare file names (regular files only, never a path) in dirAbsPath, sorted so +// callers see a deterministic order; empty on a missing or unreadable folder. +std::vector listFolderFileNames(const std::string& dirAbsPath); + +} // namespace reasampler diff --git a/src/shell/package/package_pickers.cpp b/src/shell/package/package_pickers.cpp new file mode 100644 index 0000000..b122bd9 --- /dev/null +++ b/src/shell/package/package_pickers.cpp @@ -0,0 +1,93 @@ +// package_pickers.cpp — see package_pickers.h. Export is native rather than REAPER +// API: the SDK's newer GetUserFileName(mode=0) could save, but it resolves to null +// on older REAPER builds this extension still loads in, while GetSaveFileNameW / +// BrowseForSaveFile are always present. main.cpp owns the REAPER API pointers; this +// TU gets them extern. + +#include "shell/package/package_pickers.h" + +#ifdef _WIN32 +#include +#include +#else +#include "swell/swell.h" +#endif + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetUserFileNameForRead +#define REAPERAPI_WANT_GetMainHwnd +#include "reaper_plugin_functions.h" + +namespace reasampler { + +bool pickPackageForImport(std::string& outAbsPath) { + outAbsPath.clear(); + if (!GetUserFileNameForRead) return false; + char buf[4096]; + buf[0] = '\0'; + // defext spelled without the dot, matching Win32 lpstrDefExt. [verify — DAW] + // whether REAPER's picker applies it to the shown filter. + if (!GetUserFileNameForRead(buf, "Import bank package", "rsbank")) return false; + outAbsPath = buf; + return !outAbsPath.empty(); +} + +#ifdef _WIN32 + +bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath) { + outAbsPath.clear(); + + wchar_t file[4096]; + file[0] = L'\0'; + if (!suggestedFileName.empty()) { + const int wrote = MultiByteToWideChar(CP_UTF8, 0, suggestedFileName.c_str(), + -1, file, 4096); + if (wrote <= 0) file[0] = L'\0'; // unconvertible suggestion -> empty box + } + + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = GetMainHwnd ? GetMainHwnd() : nullptr; + ofn.lpstrFilter = + L"ReaSampler bank package (*.rsbank)\0*.rsbank\0All files (*.*)\0*.*\0"; + ofn.lpstrFile = file; + ofn.nMaxFile = 4096; + ofn.lpstrTitle = L"Export bank package"; + ofn.lpstrDefExt = L"rsbank"; + // NOCHANGEDIR: REAPER's process-wide working directory is not ours to move. + ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | + OFN_HIDEREADONLY; + if (!GetSaveFileNameW(&ofn)) return false; + + const int need = + WideCharToMultiByte(CP_UTF8, 0, file, -1, nullptr, 0, nullptr, nullptr); + if (need <= 0) return false; + std::string utf8(static_cast(need), '\0'); + WideCharToMultiByte(CP_UTF8, 0, file, -1, &utf8[0], need, nullptr, nullptr); + if (!utf8.empty() && utf8.back() == '\0') utf8.pop_back(); + outAbsPath = std::move(utf8); + return !outAbsPath.empty(); +} + +#else // SWELL (macOS / Linux) + +bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath) { + outAbsPath.clear(); + // GetOpenFileName-style pair list; the literal's implicit terminator supplies the + // closing double-NUL. [verify — DAW] the exact filter strings SWELL accepts. + static const char kExtList[] = "ReaSampler bank package (*.rsbank)\0*.rsbank\0"; + char fn[4096]; + fn[0] = '\0'; + if (!BrowseForSaveFile("Export bank package", nullptr, + suggestedFileName.empty() ? nullptr + : suggestedFileName.c_str(), + kExtList, fn, static_cast(sizeof(fn)))) { + return false; + } + outAbsPath = fn; + return !outAbsPath.empty(); +} + +#endif + +} // namespace reasampler diff --git a/src/shell/package/package_pickers.h b/src/shell/package/package_pickers.h new file mode 100644 index 0000000..cdfbc1b --- /dev/null +++ b/src/shell/package/package_pickers.h @@ -0,0 +1,21 @@ +// shell/package/package_pickers — the two package file pickers, and they are +// deliberately asymmetric: import rides REAPER's own read picker +// (GetUserFileNameForRead, both platforms); export goes native — Win32 +// GetSaveFileNameW / SWELL BrowseForSaveFile — because the always-present REAPER +// surface offers no save picker. Do not symmetrize; the rationale is in the TU. + +#pragma once + +#include + +namespace reasampler { + +// REAPER's read picker. True with outAbsPath set iff the user chose a file. +bool pickPackageForImport(std::string& outAbsPath); + +// Native save picker, pre-filled with suggestedFileName (a bare name, e.g. +// "MyBank.rsbank"). True with outAbsPath set iff the user chose a destination; the +// dialog's own overwrite confirm has already run by then. +bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath); + +} // namespace reasampler diff --git a/src/shell/package/package_rollback.cpp b/src/shell/package/package_rollback.cpp new file mode 100644 index 0000000..ddface7 --- /dev/null +++ b/src/shell/package/package_rollback.cpp @@ -0,0 +1,40 @@ +// package_rollback.cpp — see package_rollback.h. The rollback delete below runs +// under the ONE carve-out from prune's exclusive file-deletion authority, stated at +// src/shell/persist/prune_fs.cpp:5-11; it satisfies that discriminator by +// construction — every recorded path was created by this journal's own +// writeLandedFile, and the abandoned index mutation means nothing ever referenced it. + +#include "shell/package/package_rollback.h" + +#include + +namespace reasampler { + +namespace fs = std::filesystem; + +bool LandedFileJournal::writeLandedFile(const std::string& absPath, + const PayloadBuffer& payload) { + if (payload.empty()) return false; + std::error_code ec; + if (fs::exists(absPath, ec) || ec) return false; + PackageFileWriter writer(absPath); + if (!writer.appendPayload(payload)) return false; // dtor aborts; temp removed + if (!writer.commit()) return false; + paths_.push_back(absPath); + return true; +} + +RollbackResult LandedFileJournal::rollback() { + RollbackResult result; + for (const std::string& path : paths_) { + std::error_code ec; + const bool removed = fs::remove(path, ec); + if (removed) ++result.deletedCount; + else if (ec) ++result.failedCount; + else ++result.alreadyAbsentCount; // no error, nothing there + } + paths_.clear(); + return result; +} + +} // namespace reasampler diff --git a/src/shell/package/package_rollback.h b/src/shell/package/package_rollback.h new file mode 100644 index 0000000..a72f89c --- /dev/null +++ b/src/shell/package/package_rollback.h @@ -0,0 +1,45 @@ +// shell/package/package_rollback — the files ONE import call has landed, as a +// journal: writes record themselves on success, and rollback() deletes exactly what +// is recorded — a path this import did not write is structurally impossible to hand +// it. The deletion carve-out this satisfies is cited at package_rollback.cpp's +// header. + +#pragma once + +#include +#include + +#include "shell/package/package_io.h" + +namespace reasampler { + +struct RollbackResult { + int deletedCount = 0; + int alreadyAbsentCount = 0; // vanished between land and rollback — not a failure + int failedCount = 0; // locked / permission — recorded, never thrown +}; + +// The evidence for the rollback discriminator: only paths this journal's own +// writeLandedFile successfully created are recorded, so rollback() can never touch a +// byte this import did not write. +class LandedFileJournal { +public: + // Lands one payload at absPath through the atomic temp+rename writer and records + // the path on success. REFUSES an existing destination — a bank-folder file is + // never overwritten; collision handling is the import plan's job, upstream. An + // empty payload is refused too: it signals an upstream read failure, never a + // real entry. + bool writeLandedFile(const std::string& absPath, const PayloadBuffer& payload); + + // Deletes exactly the recorded files and clears the journal, so a second call is + // a no-op. Hard unlink, not trash: nothing ever referenced these bytes. + RollbackResult rollback(); + + const std::vector& landedPaths() const { return paths_; } + bool empty() const { return paths_.empty(); } + +private: + std::vector paths_; +}; + +} // namespace reasampler diff --git a/tests/test_package_io.cpp b/tests/test_package_io.cpp new file mode 100644 index 0000000..7f10b73 --- /dev/null +++ b/tests/test_package_io.cpp @@ -0,0 +1,228 @@ +// Standalone tests for shell/package/package_io — no REAPER, no framework. Pins the +// two properties the seam exists for: an interrupted or failed write leaves the +// destination absent or holding its prior contents (failure injected at the writer +// seam — abandonment, open failure, rename failure), and a multi-entry round trip +// holds at most one entry's payload, asserted against PayloadBuffer::alive() — the +// seam counter — rather than a memory measurement. + +#include "../src/shell/package/package_io.h" + +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static std::vector patternBytes(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) + v[i] = static_cast(seed + i * 7u); + return v; +} + +static void writeScratchFile(const std::string& path, + const std::vector& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readAll(const std::string& path) { + std::ifstream f(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static void testPayloadCounterTracksMovesNotCopies() { + CHECK(PayloadBuffer::alive() == 0); + { + PayloadBuffer a(patternBytes(4, 1)); + CHECK(PayloadBuffer::alive() == 1); + PayloadBuffer b = std::move(a); + CHECK(PayloadBuffer::alive() == 1); // the count moved with the bytes + PayloadBuffer c; + c = std::move(b); + CHECK(PayloadBuffer::alive() == 1); + CHECK(c.size() == 4); + } + CHECK(PayloadBuffer::alive() == 0); + { + PayloadBuffer empty; + CHECK(PayloadBuffer::alive() == 0); // holding nothing counts as nothing + } +} + +static void testStreamingRoundTripHoldsOnePayload() { + const std::string dest = "pkg_io_scratch.rsbank"; + const std::vector header = patternBytes(16, 0xA0); + const std::vector> entries = { + patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)}; + + std::vector srcs; + for (std::size_t i = 0; i < entries.size(); ++i) { + srcs.push_back("pkg_io_src" + std::to_string(i) + ".bin"); + writeScratchFile(srcs[i], entries[i]); + } + + CHECK(PayloadBuffer::alive() == 0); + std::vector> layout; // offset, length + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(header.data(), header.size())); + std::uint64_t offset = header.size(); + for (std::size_t i = 0; i < entries.size(); ++i) { + PayloadBuffer p = readFilePayload(srcs[i]); + CHECK(p.bytes() == entries[i]); + CHECK(PayloadBuffer::alive() == 1); // exactly one entry in memory + CHECK(writer.appendPayload(p)); + layout.emplace_back(offset, p.size()); + offset += p.size(); + } + CHECK(PayloadBuffer::alive() == 0); // each released before the next + CHECK(writer.commit()); + CHECK(!writer.commit()); // a second commit is refused + } + CHECK(!fs::exists(dest + ".rsbanktmp")); + CHECK(fs::exists(dest)); + + { + // Scoped: the reader holds the file open, and Windows refuses to delete an + // open file — cleanup below needs it closed first. + PackageFileReader reader(dest); + CHECK(reader.ok()); + CHECK(reader.fileSize() == header.size() + 1000 + 500 + 1); + for (std::size_t i = 0; i < entries.size(); ++i) { + PayloadBuffer p = reader.readRange(layout[i].first, layout[i].second); + CHECK(PayloadBuffer::alive() == 1); // one entry per readRange, no more + CHECK(p.bytes() == entries[i]); + } + CHECK(PayloadBuffer::alive() == 0); + } + + std::error_code ec; + for (const std::string& s : srcs) fs::remove(s, ec); + fs::remove(dest, ec); +} + +static void testAbandonedWriteLeavesNoDestination() { + const std::string dest = "pkg_io_abandon.rsbank"; + { + PackageFileWriter writer(dest); + const std::vector some = patternBytes(64, 9); + CHECK(writer.appendRaw(some.data(), some.size())); + // no commit — destruction is the injected interruption + } + CHECK(!fs::exists(dest)); + CHECK(!fs::exists(dest + ".rsbanktmp")); +} + +static void testAbortPreservesPriorContents() { + const std::string dest = "pkg_io_prior.rsbank"; + const std::vector prior = patternBytes(32, 0x40); + writeScratchFile(dest, prior); + { + PackageFileWriter writer(dest); + const std::vector some = patternBytes(64, 9); + CHECK(writer.appendRaw(some.data(), some.size())); + writer.abort(); + CHECK(!writer.appendRaw(some.data(), some.size())); // dead after abort + CHECK(!writer.commit()); + } + CHECK(readAll(dest) == prior); + CHECK(!fs::exists(dest + ".rsbanktmp")); + std::error_code ec; + fs::remove(dest, ec); +} + +static void testOpenFailureIsInert() { + const std::string dest = "pkg_io_no_such_dir/x.rsbank"; + PackageFileWriter writer(dest); + CHECK(!writer.ok()); + const std::vector some = patternBytes(8, 1); + CHECK(!writer.appendRaw(some.data(), some.size())); + CHECK(!writer.commit()); + CHECK(!fs::exists("pkg_io_no_such_dir")); +} + +static void testCommitRenameFailureSelfCleans() { + // A directory squatting on the destination makes the final rename fail — a real + // injected commit failure, not a simulated one. + const std::string dest = "pkg_io_dir.rsbank"; + std::error_code ec; + fs::create_directory(dest, ec); + CHECK(!ec); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + const std::vector some = patternBytes(8, 1); + CHECK(writer.appendRaw(some.data(), some.size())); + CHECK(!writer.commit()); + } + CHECK(fs::is_directory(dest)); // prior state intact + CHECK(!fs::exists(dest + ".rsbanktmp")); + fs::remove(dest, ec); +} + +static void testReaderEdges() { + PackageFileReader missing("pkg_io_no_such_file.rsbank"); + CHECK(!missing.ok()); + CHECK(missing.readRange(0, 1).empty()); + + const std::string path = "pkg_io_edges.bin"; + const std::vector bytes = patternBytes(10, 5); + writeScratchFile(path, bytes); + { + // Scoped: the reader must be closed before the cleanup remove below. + PackageFileReader reader(path); + CHECK(reader.ok()); + CHECK(reader.fileSize() == 10); + CHECK(reader.readRange(5, 10).empty()); // past the end + CHECK(reader.readRange(10, 1).empty()); // starts at the end + CHECK(reader.readRange(0, 0).empty()); // zero length is failure, one branch + const PayloadBuffer slice = reader.readRange(2, 3); + CHECK(slice.bytes() == + std::vector(bytes.begin() + 2, bytes.begin() + 5)); + } + std::error_code ec; + fs::remove(path, ec); +} + +static void testListFolderFileNames() { + const std::string dir = "pkg_io_listdir"; + std::error_code ec; + fs::create_directory(dir, ec); + writeScratchFile(dir + "/b.bin", patternBytes(2, 1)); + writeScratchFile(dir + "/a.bin", patternBytes(2, 2)); + fs::create_directory(dir + "/sub", ec); + writeScratchFile(dir + "/sub/c.bin", patternBytes(2, 3)); + + const std::vector names = listFolderFileNames(dir); + CHECK(names == (std::vector{"a.bin", "b.bin"})); // sorted, bare, non-recursive + CHECK(listFolderFileNames("pkg_io_no_such_dir").empty()); + + fs::remove_all(dir, ec); +} + +int main() { + testPayloadCounterTracksMovesNotCopies(); + testStreamingRoundTripHoldsOnePayload(); + testAbandonedWriteLeavesNoDestination(); + testAbortPreservesPriorContents(); + testOpenFailureIsInert(); + testCommitRenameFailureSelfCleans(); + testReaderEdges(); + testListFolderFileNames(); + + if (g_fail == 0) std::printf("package_io: all tests passed\n"); + else std::printf("package_io: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_package_rollback.cpp b/tests/test_package_rollback.cpp new file mode 100644 index 0000000..6c28bfa --- /dev/null +++ b/tests/test_package_rollback.cpp @@ -0,0 +1,123 @@ +// Standalone tests for shell/package/package_rollback — no REAPER, no framework. +// Pins the discriminator's mechanics: a file is recorded only when this journal's +// own write landed it, a pre-existing destination is refused untouched, and +// rollback deletes exactly the recorded set — a bystander file beside them stays, +// and a vanished file is tolerated rather than failed. + +#include "../src/shell/package/package_rollback.h" + +#include +#include +#include +#include +#include + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static std::vector patternBytes(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) + v[i] = static_cast(seed + i * 7u); + return v; +} + +static void writeScratchFile(const std::string& path, + const std::vector& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readAll(const std::string& path) { + std::ifstream f(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static void testLandRecordsOnSuccessOnly() { + LandedFileJournal journal; + const std::string path = "rb_land.bin"; + const std::vector bytes = patternBytes(32, 1); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + CHECK(readAll(path) == bytes); + CHECK(journal.landedPaths() == (std::vector{path})); + CHECK(!fs::exists(path + ".rsbanktmp")); + journal.rollback(); + CHECK(!fs::exists(path)); +} + +static void testExistingDestinationRefusedUntouched() { + LandedFileJournal journal; + const std::string path = "rb_existing.bin"; + const std::vector original = patternBytes(16, 0x60); + writeScratchFile(path, original); + + CHECK(!journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1)))); + CHECK(readAll(path) == original); // never overwritten + CHECK(journal.empty()); // a refused write is not recorded + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 0); + CHECK(fs::exists(path)); // rollback cannot touch a file it did not write + std::error_code ec; + fs::remove(path, ec); +} + +static void testEmptyPayloadRefused() { + LandedFileJournal journal; + CHECK(!journal.writeLandedFile("rb_empty.bin", PayloadBuffer{})); + CHECK(!fs::exists("rb_empty.bin")); + CHECK(journal.empty()); +} + +static void testRollbackDeletesExactlyTheRecordedSet() { + LandedFileJournal journal; + CHECK(journal.writeLandedFile("rb_a.bin", PayloadBuffer(patternBytes(8, 1)))); + CHECK(journal.writeLandedFile("rb_c.bin", PayloadBuffer(patternBytes(8, 2)))); + writeScratchFile("rb_bystander.bin", patternBytes(8, 3)); // not journal-written + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 2); + CHECK(result.alreadyAbsentCount == 0); + CHECK(result.failedCount == 0); + CHECK(!fs::exists("rb_a.bin")); + CHECK(!fs::exists("rb_c.bin")); + CHECK(fs::exists("rb_bystander.bin")); // exactly the given files, nothing else + CHECK(journal.empty()); + + const RollbackResult second = journal.rollback(); // cleared: a no-op + CHECK(second.deletedCount == 0); + CHECK(fs::exists("rb_bystander.bin")); + std::error_code ec; + fs::remove("rb_bystander.bin", ec); +} + +static void testVanishedFileIsToleratedNotFailed() { + LandedFileJournal journal; + CHECK(journal.writeLandedFile("rb_gone.bin", PayloadBuffer(patternBytes(8, 1)))); + std::error_code ec; + fs::remove("rb_gone.bin", ec); // vanished between land and rollback + CHECK(!ec); + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 0); + CHECK(result.alreadyAbsentCount == 1); + CHECK(result.failedCount == 0); +} + +int main() { + testLandRecordsOnSuccessOnly(); + testExistingDestinationRefusedUntouched(); + testEmptyPayloadRefused(); + testRollbackDeletesExactlyTheRecordedSet(); + testVanishedFileIsToleratedNotFailed(); + + if (g_fail == 0) std::printf("package_rollback: all tests passed\n"); + else std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}