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..b4db0c4 --- /dev/null +++ b/src/shell/package/CLAUDE.md @@ -0,0 +1,112 @@ +# src/shell/package — package filesystem + dialog seam + +## Scope + +The filesystem and dialog acts behind bank-package export/import: streaming package +file I/O plus the file-status and exclusive-create acts (`package_io`), the +UTF-8 path conversion every one of them goes through (`package_path`), the landed-file +journal and its rollback delete (`package_rollback`), and the two 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 + +- **Paths cross this seam as UTF-8 narrow strings and are converted through + `utf8Path()` before ANY filesystem call.** This is not decoration: on Windows + `std::filesystem` decodes a narrow path through the runtime ANSI code page (measured + `GetACP() == 1252`), so a bare `fs::path(std::string)` turns `café.rsbank` into + `café.rsbank` or fails to open it. Every path a verb hands in or gets back — + including `listFolderFileNames`' results, which go through `pathToUtf8()` and never + `string()` — is UTF-8. `core/util/file_bytes` has the un-converted shape, which is + why `readFilePayload` reads through this module's own `PackageFileReader` instead. +- **Atomic package write, to the limit of a rename.** 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. This is process-crash atomic, NOT power-loss atomic: `commit()` flushes + and closes but does not `fsync`/`FlushFileBuffers`, so a power cut can still leave a + renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real + stall, and the failure this design targets is a refused or interrupted export. +- **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. +- **An empty `PayloadBuffer` is a failure signal, never an entry.** It is the seam's + one "nothing to work with" branch, so both `PackageFileWriter::appendPayload` and + `writeFileExclusive` refuse it — appending it would let a verb commit framing that + claims bytes nobody wrote. `appendRaw(ptr, 0)` stays tolerated: framing has + legitimate zero-length edges. +- **No overwrite of a bank-folder file, ever — and the create is the check.** + `writeLandedFile` lands through `writeFileExclusive` (`O_EXCL` / `_O_EXCL`), so the + refusal of an occupied path is one atomic act rather than an `exists()` a concurrent + writer could win the race against. Collision handling (auto-rename) remains 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. **Known gap, obligation on the export verb:** + `pickPackageSavePath`'s own `.rsbank` re-append (see its Gotcha below) can turn a + confirmed path `X` into a write target `X.rsbank` that the dialog never asked about. + The export verb MUST re-check `fileStatus()` on the path actually handed to + `PackageFileWriter` — after any extension append — and get its own consent if that + re-checked path is `Present`; the dialog's confirm only ever covered the pre-append + path. Not fixed at this seam: prompting is verb-level UX, and `pickPackageSavePath` + has no caller yet, so the gap is latent, not live. +- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.** + The citation and the full discriminator live at `package_rollback.cpp`'s header. + "Did this call create it" is structural: only exclusively-created paths are + recorded, resolved absolute at record time so a later CWD change cannot re-aim the + delete. "Did anything ever reference it" is a **contract the import verb must + honour**: it MUST call `markIndexCommitted()` only AFTER the index write has + returned success — calling it before, then having that write fail, strands the + landed files with no index entry and a journal that now refuses to roll them + back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying + an armed journal without calling either does NOT roll it back — see + `LandedFileJournal`'s own doc comment.) +- **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export. + There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT` + without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails + to resolve, so a build that can load us cannot lack it. + +## Modules + +- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point. +- `package_io` — every filesystem act the verbs need: `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), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW. +- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW. +- `package_pickers` — `pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`. Compile-only until the verbs land; nothing here can be exercised in a unit test. + +## Gotchas + +- A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no + picker filter matches it), and a later export to the same destination truncates it. + A crash mid-import strands a partial bank file under its real name instead — the + land is a direct exclusive create, not temp+rename. Either way the debris was never + recorded in the tracking ledger, so prune sees a foreign file (not owned, never an + orphan) and will not touch it; removal is by hand. `[verify — DAW]` whether the + import verb should pre-clean stale debris when it lands. +- The picker filter and mode arguments are spelled to `GetUserFileName`'s documented + pair format but are `[verify — DAW]` on all three platforms — neither picker is + exercised outside a live REAPER session. `GetUserFileName` also takes no owner + window, so dialog parenting is REAPER's to do; the superseded Win32 path passed + `GetMainHwnd()` explicitly. Also `[verify — DAW]`: whether mode 0's picker appends + an extension from `extension_list` when the user omits one — `pickPackageSavePath` + re-appends `.rsbank` itself so the returned path is correct regardless of how that + lands (the superseded Win32 path had `ofn.lpstrDefExt` for this; `GetUserFileName` + has no equivalent parameter). The re-append is suffix-blind: it only skips when the + path already ends in `.rsbank`, so a path carrying a DIFFERENT extension gets + `.rsbank` appended after it (`mybank.bak` → `mybank.bak.rsbank`), unlike the + superseded `ofn.lpstrDefExt`, which appended only when the path had no extension at + all. Defensible for a format-locked export, but a real divergence from the old + picker's behavior — whoever tests the picker under `[verify — DAW]` should expect + the double-extension result on a path that already has one. +- `pickPackageSavePath`'s `suggestedPath` doubles as the dialog's starting directory + when it is a full path. The verbs should seed it from the project directory — + passing a bare name leaves the dialog on REAPER's process working directory, which + is its install or resource path. +- `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by + design (the one "nothing to work with" branch). **Cross-track contract, not a local + rule:** a genuinely zero-length entry cannot round-trip through this seam, so + `core/package`'s 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..aff4414 --- /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) +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 pickers call the REAPER API, so no test target can exercise them; declared as a +# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows. +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..c268742 --- /dev/null +++ b/src/shell/package/package_io.cpp @@ -0,0 +1,268 @@ +// package_io.cpp — see package_io.h for the seam's contract. Non-throwing at the +// boundary about filesystem_error: every filesystem call uses the error_code form. +// Allocation can still throw bad_alloc — readRange's sanity ceiling exists to keep +// that surface small, not to remove it. + +#include "shell/package/package_io.h" + +#include +#include +#include +#include + +#include "shell/package/package_path.h" + +#ifdef _WIN32 +#include +#include +#include +#include +#else +#include +#include +#include +#endif + +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(const std::string& destAbsPath) + : destPath_(utf8Path(destAbsPath)), tempPath_(destPath_) { + tempPath_ += ".rsbanktmp"; // += concatenates; / would make it a child + 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) { + if (payload.empty()) { + ok_ = false; // the stream is now short of what the framing will claim + return false; + } + 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) { + const fs::path path = utf8Path(srcAbsPath); + std::error_code ec; + const std::uintmax_t sz = fs::file_size(path, ec); + if (ec) return; + in_.open(path, 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. + // Also rejected here rather than truncated: a length that would not fit in + // size_t (possible on a 32-bit build, where streamsize below stays 64-bit and + // so would read past a truncated allocation) and a length past the sanity + // ceiling, which exists so a merely large-but-real file size can't still hand + // std::vector a multi-gigabyte demand. + // + // `length > size_` short-circuits before the two branches below ever see a real + // file, so neither is reachable without a genuine >4 GiB fixture — this guard + // ships unexercised by test_package_io.cpp, which covers past-the-end, + // starts-at-the-end, and zero-length only. The ordering (cheap size check first) + // is deliberate and correct; it is not reordered to make the branch testable. + constexpr std::uint64_t kMaxReadRangeBytes = std::uint64_t{4} << 30; // 4 GiB + if (!ok_ || length == 0 || length > size_ || offset > size_ - length || + length > kMaxReadRangeBytes || + length > static_cast(std::numeric_limits::max())) { + 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)); +} + +// --------------------------------------------------------------------------- + +// Deliberately NOT core/util/file_bytes: that loader takes an unconverted narrow path +// (see package_path.h), and this seam's own reader already goes through utf8Path. +PayloadBuffer readFilePayload(const std::string& absPath) { + PackageFileReader reader(absPath); + return reader.readRange(0, reader.fileSize()); +} + +FileStatus fileStatus(const std::string& absPath) { + const fs::path path = utf8Path(absPath); + std::error_code ec; + const fs::file_status st = fs::status(path, ec); + // status() reports not_found through the type AND sets ec, so the type is the + // discriminator; an ec with any other type is a real access failure. + if (st.type() == fs::file_type::not_found) return FileStatus::Absent; + if (ec || !fs::is_regular_file(st)) return FileStatus::Unreadable; + std::ifstream probe(path, std::ios::binary); + return probe ? FileStatus::Present : FileStatus::Unreadable; +} + +bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload) { + if (payload.empty()) return false; + const fs::path path = utf8Path(absPath); + + int fd = -1; +#ifdef _WIN32 + if (_wsopen_s(&fd, path.wstring().c_str(), + _O_WRONLY | _O_CREAT | _O_EXCL | _O_BINARY, _SH_DENYNO, + _S_IREAD | _S_IWRITE) != 0) { + return false; + } +#else + fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644); +#endif + if (fd < 0) return false; + + bool ok = true; + std::size_t written = 0; + while (written < payload.size()) { + // Chunked because the Windows _write count is an unsigned int, not size_t. + const std::size_t chunk = + std::min(payload.size() - written, 1u << 20); +#ifdef _WIN32 + const int n = _write(fd, payload.data() + written, + static_cast(chunk)); +#else + const ssize_t n = ::write(fd, payload.data() + written, chunk); + if (n < 0 && errno == EINTR) continue; // a signal on the UI thread isn't a failure +#endif + if (n <= 0) { + ok = false; + break; + } + written += static_cast(n); + } + +#ifdef _WIN32 + _close(fd); +#else + ::close(fd); +#endif + + if (!ok) { + // Self-cleanup, not deletion authority: this call created the file moments + // ago and nothing has ever referenced it (prune_fs.cpp's carve-out). + std::error_code ec; + fs::remove(path, ec); + } + return ok; +} + +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(utf8Path(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(pathToUtf8(entry.path().filename())); // never .string(): ANSI + } + 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..adf60ec --- /dev/null +++ b/src/shell/package/package_io.h @@ -0,0 +1,127 @@ +// shell/package/package_io — every filesystem act the export/import verbs need: +// streaming package read/write, whole-file payload read, folder listing, file status, +// and the exclusive create that lands one bank file. Bytes only — what a package +// contains is core/package's business. Blocking I/O: UI-thread actions only, never +// the audio thread. + +#pragma once + +#include +#include +#include +#include +#include + +namespace reasampler { + +// One entry's payload. Move-only, because a copy would silently double the bytes the +// seam promises to hold at most one of; alive() is the counter that makes that +// promise assertable instead of aspirational. +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(); } + + // Buffers currently holding at least one byte, process-wide. + static int alive(); + +private: + void release(); + + std::vector bytes_; + bool counted_ = false; +}; + +// Streaming atomic writer; paths cross this seam as UTF-8 narrow strings and are held +// as fs::path internally. The temp sibling is created in the DESTINATION's own +// directory so commit()'s rename never crosses a volume — a cross-device rename +// degrades to a copy and stops being atomic. commit() REPLACES an existing +// destination (the deliberate asymmetry against LandedFileJournal; see CLAUDE.md). +class PackageFileWriter { +public: + explicit PackageFileWriter(const 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. A zero + // length is accepted — framing has legitimate zero-length edges. + bool appendRaw(const std::uint8_t* data, std::size_t len); + // One entry's bytes. Also false — and the writer poisoned — on an EMPTY payload: + // empty is this seam's one "nothing to work with" signal, so accepting it would + // let a verb commit a package whose framing claims bytes nobody wrote. + 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::filesystem::path& destPath() const { return destPath_; } + const std::filesystem::path& tempPath() const { return tempPath_; } + +private: + std::filesystem::path destPath_; + std::filesystem::path 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. Use fileStatus() when the two must be told apart. +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, and capped + // against a 4 GiB sanity ceiling so a merely large-but-real file can't still + // force a multi-gigabyte allocation out of one call. + 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. Empty on any failure, per PackageFileReader — including a source file over +// readRange's 4 GiB ceiling, which reads as empty exactly like an unreadable file; +// fileStatus() cannot tell the two apart either, since it only checks openability. +PayloadBuffer readFilePayload(const std::string& absPath); + +// Export must tell a missing indexed file from an unreadable one in its refusal +// message; readFilePayload deliberately cannot, since both fail to an empty buffer. +enum class FileStatus { Present, Absent, Unreadable }; +FileStatus fileStatus(const std::string& absPath); + +// Creates absPath and writes the payload, failing if ANYTHING already occupies the +// path. The create IS the existence check (O_EXCL / CREATE_NEW), so nothing can slip +// in between: an exists()-then-write pair would let a file created in that window be +// overwritten and then deleted by a rollback that believes it wrote it. Refuses an +// empty payload, and removes its own partial file on a mid-write failure. Not +// temp+rename — an exclusive rename has no portable spelling, and the debris a crash +// leaves here is unrecorded and unindexed either way. +bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload); + +// Bare file names (regular files only, never a path) in dirAbsPath, UTF-8, 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_path.h b/src/shell/package/package_path.h new file mode 100644 index 0000000..e70db66 --- /dev/null +++ b/src/shell/package/package_path.h @@ -0,0 +1,26 @@ +// shell/package/package_path — the ONE narrow-string <-> fs::path conversion pair for +// this seam. std::filesystem decodes a narrow path through the RUNTIME ANSI code page +// on Windows (measured: GetACP() == 1252 here), never UTF-8, so a bare +// fs::path(std::string) turns every non-ASCII path this repo's UTF-8 convention +// produces into mojibake. u8path is the C++17 spelling; it is deprecated in C++20, so +// a standard bump replaces both bodies here rather than at every call site. + +#pragma once + +#include +#include + +namespace reasampler { + +inline std::filesystem::path utf8Path(const std::string& utf8) { + return std::filesystem::u8path(utf8); +} + +// u8string() returns std::u8string in C++20 — this is the one place that narrows it +// back to std::string, so a standard bump only widens this one body. +inline std::string pathToUtf8(const std::filesystem::path& path) { + const auto u8 = path.u8string(); + return std::string(u8.begin(), u8.end()); +} + +} // namespace reasampler diff --git a/src/shell/package/package_pickers.cpp b/src/shell/package/package_pickers.cpp new file mode 100644 index 0000000..16395a6 --- /dev/null +++ b/src/shell/package/package_pickers.cpp @@ -0,0 +1,63 @@ +// package_pickers.cpp — see package_pickers.h. GetUserFileName cannot be null here: +// main.cpp defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL, so the generated +// resolver walks the FULL table, and it aborts the extension load if any single name +// fails to resolve. A fallback picker would be unreachable code. + +#include "shell/package/package_pickers.h" + +#include +#include + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetUserFileName +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// GetUserFileName's documented pair format: label|pattern|label|pattern. +const char kExtList[] = + "ReaSampler bank package (*.rsbank)|*.rsbank|All files (*.*)|*.*"; + +bool runPicker(int mode, const char* caption, const char* initial, + std::string& outAbsPath) { + outAbsPath.clear(); + char buf[4096]; + buf[0] = '\0'; + if (!GetUserFileName(mode, caption, initial, kExtList, buf, + static_cast(sizeof(buf)))) { + return false; + } + outAbsPath = buf; + return !outAbsPath.empty(); +} + +bool hasCaseInsensitiveSuffix(const std::string& path, const std::string& suffix) { + if (path.size() < suffix.size()) return false; + return std::equal(suffix.rbegin(), suffix.rend(), path.rbegin(), + [](unsigned char a, unsigned char b) { + return std::tolower(a) == std::tolower(b); + }); +} + +} // namespace + +bool pickPackageForImport(std::string& outAbsPath) { + return runPicker(1, "Import bank package", "", outAbsPath); +} + +bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath) { + // [verify — DAW] GetUserFileName takes no owner window, so the dialog's parenting + // is REAPER's to do; the previous Win32 path passed GetMainHwnd() explicitly. + if (!runPicker(0, "Export bank package", suggestedPath.c_str(), outAbsPath)) { + return false; + } + // GetUserFileName has no lpstrDefExt equivalent (the old Win32 picker's + // ofn.lpstrDefExt = L"rsbank"); whether mode 0 appends one itself from + // kExtList is [verify — DAW], so append it ourselves whenever it's missing. + if (!hasCaseInsensitiveSuffix(outAbsPath, ".rsbank")) outAbsPath += ".rsbank"; + return true; +} + +} // namespace reasampler diff --git a/src/shell/package/package_pickers.h b/src/shell/package/package_pickers.h new file mode 100644 index 0000000..9f60512 --- /dev/null +++ b/src/shell/package/package_pickers.h @@ -0,0 +1,24 @@ +// shell/package/package_pickers — the two package file pickers, both on REAPER's own +// GetUserFileName (mode 1 = existing file, mode 0 = new file). No native/platform +// split: the SDK's save mode is not optional on any build that can load this +// extension. Paths in and out are UTF-8, matching this tree's established practice +// for narrow strings crossing the REAPER API (see instrument_drop_win.cpp's +// path.u8string() to TrackFX_SetPreset, or prune_fs.cpp's CP_UTF8 conversion) — the +// SDK header itself never says "UTF-8". + +#pragma once + +#include + +namespace reasampler { + +// True with outAbsPath set iff the user chose a file. +bool pickPackageForImport(std::string& outAbsPath); + +// suggestedPath is a bare file name ("MyBank.rsbank") or a full path — a full one +// also seeds the dialog's starting directory, which is how a caller keeps the picker +// off REAPER's process working directory. 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& suggestedPath, 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..c3162d0 --- /dev/null +++ b/src/shell/package/package_rollback.cpp @@ -0,0 +1,51 @@ +// 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. That discriminator has two clauses and this +// journal makes only the FIRST structural: "did this call create it" is guaranteed by +// recording exclusively-created paths, but "did anything ever reference it" is a +// claim about the caller's ordering — hence markIndexCommitted(), which the import +// verb must fire at the index commit so a later rollback() refuses instead of +// deleting indexed files. + +#include "shell/package/package_rollback.h" + +#include + +#include "shell/package/package_path.h" + +namespace reasampler { + +namespace fs = std::filesystem; + +bool LandedFileJournal::writeLandedFile(const std::string& destPath, + const PayloadBuffer& payload) { + if (indexCommitted_) return false; + + std::error_code ec; + const fs::path resolved = fs::absolute(utf8Path(destPath), ec); + if (ec) return false; + const std::string absPath = pathToUtf8(resolved); + + if (!writeFileExclusive(absPath, payload)) return false; + paths_.push_back(absPath); + return true; +} + +RollbackResult LandedFileJournal::rollback() { + RollbackResult result; + if (indexCommitted_) { + result.refused = true; + return result; + } + for (const std::string& path : paths_) { + std::error_code ec; + const bool removed = fs::remove(utf8Path(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..438f168 --- /dev/null +++ b/src/shell/package/package_rollback.h @@ -0,0 +1,59 @@ +// 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. The deletion carve-out this satisfies, and the half of it the caller +// still owns, are 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 + bool refused = false; // markIndexCommitted() ran: nothing was deleted +}; + +// Destroying an armed (uncommitted, un-rolled-back) journal is NOT an implicit +// rollback — the caller must call rollback() itself on the failure path it wants +// to undo. That's the fail-safe direction: a journal dropped by an unrelated early +// return leaves the landed files in place rather than silently deleting them. +class LandedFileJournal { +public: + // Lands one payload at destPath through the exclusive create (which refuses an + // occupied path outright — a bank-folder file is never overwritten, and collision + // handling is the import plan's job upstream) and records it on success. An empty + // payload is refused, per writeFileExclusive. Relative paths are resolved against + // the process CWD before the write, so the journal's record is always absolute + // and a later CWD change cannot re-aim the delete. Refused once + // markIndexCommitted() has run. + bool writeLandedFile(const std::string& destPath, const PayloadBuffer& payload); + + // Disarms the journal: the index mutation these files back is committed, so they + // are now referenced bytes and the carve-out no longer covers them. This is the + // half of prune's discriminator the journal cannot make structural on its own — + // the import verb MUST call this only AFTER the index write has returned success. + // Calling it before, then having that write fail, strands the landed files with + // no index entry and a journal that now refuses to roll them back. + void markIndexCommitted() { indexCommitted_ = true; } + bool indexCommitted() const { return indexCommitted_; } + + // 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. Refuses + // (deleting nothing, keeping the record) once markIndexCommitted() has run. + RollbackResult rollback(); + + const std::vector& landedPaths() const { return paths_; } + bool empty() const { return paths_.empty(); } + +private: + std::vector paths_; + bool indexCommitted_ = false; +}; + +} // namespace reasampler diff --git a/tests/test_package_io.cpp b/tests/test_package_io.cpp new file mode 100644 index 0000000..d6237f6 --- /dev/null +++ b/tests/test_package_io.cpp @@ -0,0 +1,348 @@ +// Standalone tests for shell/package/package_io — no REAPER, no framework. Failure is +// injected at the writer seam (abandonment, open failure, rename failure) rather than +// simulated, and the streaming claim is asserted against PayloadBuffer::alive(). + +#include "../src/shell/package/package_io.h" +#include "../src/shell/package/package_path.h" + +#include +#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 bool sameBytes(const PayloadBuffer& p, const std::vector& want) { + return p.size() == want.size() && + (want.empty() || std::equal(want.begin(), want.end(), p.data())); +} + +static void writeScratchFile(const std::string& path, + const std::vector& bytes) { + std::ofstream f(utf8Path(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(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } + +static void removeQuietly(const std::string& path) { + std::error_code ec; + fs::remove(utf8Path(path), ec); +} + +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(sameBytes(p, 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(!exists(dest + ".rsbanktmp")); + CHECK(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(sameBytes(p, entries[i])); + } + CHECK(PayloadBuffer::alive() == 0); + } + + for (const std::string& s : srcs) removeQuietly(s); + removeQuietly(dest); +} + +static void testEmptyPayloadIsRefusedAndPoisonsTheWriter() { + // The failure this guards: an unreadable source yields an empty payload, and a + // verb that trusted a `true` here would commit framing claiming bytes nobody wrote. + const std::string dest = "pkg_io_emptypayload.rsbank"; + { + PackageFileWriter writer(dest); + const std::vector head = patternBytes(4, 1); + CHECK(writer.appendRaw(head.data(), head.size())); + CHECK(!writer.appendPayload(PayloadBuffer{})); + CHECK(!writer.ok()); + CHECK(!writer.commit()); // the short stream can never reach the destination + } + CHECK(!exists(dest)); + CHECK(!exists(dest + ".rsbanktmp")); + // A zero-length appendRaw stays tolerated — framing has legitimate empty edges. + { + PackageFileWriter writer(dest); + CHECK(writer.appendRaw(nullptr, 0)); + CHECK(writer.ok()); + writer.abort(); + } +} + +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(!exists(dest)); + CHECK(!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(!exists(dest + ".rsbanktmp")); + removeQuietly(dest); +} + +static void testCommitReplacesAnExistingFile() { + // The module's one deliberate asymmetry against LandedFileJournal's never-overwrite + // rule: the save dialog's overwrite confirm is the consent, so commit() replaces. + const std::string dest = "pkg_io_replace.rsbank"; + const std::vector prior = patternBytes(40, 0x11); + const std::vector fresh = patternBytes(7, 0x22); + writeScratchFile(dest, prior); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(fresh.data(), fresh.size())); + CHECK(writer.commit()); + } + CHECK(readAll(dest) == fresh); + CHECK(!exists(dest + ".rsbanktmp")); + removeQuietly(dest); +} + +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(!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(utf8Path(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(utf8Path(dest))); // prior state intact + CHECK(!exists(dest + ".rsbanktmp")); + fs::remove(utf8Path(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 + CHECK(sameBytes(reader.readRange(2, 3), + std::vector(bytes.begin() + 2, bytes.begin() + 5))); + } + removeQuietly(path); +} + +static void testFileStatusSeparatesAbsentFromUnreadable() { + CHECK(fileStatus("pkg_io_no_such_file.bin") == FileStatus::Absent); + + const std::string path = "pkg_io_status.bin"; + writeScratchFile(path, patternBytes(4, 1)); + CHECK(fileStatus(path) == FileStatus::Present); + removeQuietly(path); + + // A directory in a file's place is not a readable file — the export message must + // not report it as simply missing. + const std::string dir = "pkg_io_status_dir"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + CHECK(fileStatus(dir) == FileStatus::Unreadable); + fs::remove(utf8Path(dir), ec); +} + +static void testNonAsciiPathsRoundTripAsUtf8() { + // "café" in UTF-8. Windows decodes a narrow std::filesystem path through the ANSI + // code page, so an unconverted seam lands "café" or fails outright. + const std::string dir = "pkg_io_caf\xC3\xA9_dir"; + const std::string name = "caf\xC3\xA9.rsbank"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + CHECK(!ec); + + const std::string dest = dir + "/" + name; + const std::vector bytes = patternBytes(24, 0x5A); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(bytes.data(), bytes.size())); + CHECK(writer.commit()); + } + CHECK(exists(dest)); + CHECK(fileStatus(dest) == FileStatus::Present); + CHECK(sameBytes(readFilePayload(dest), bytes)); + // The listing must hand the name back in the same encoding it was given. + CHECK(listFolderFileNames(dir) == (std::vector{name})); + + fs::remove_all(utf8Path(dir), ec); +} + +static void testWriteFileExclusiveRefusesAnOccupiedPath() { + const std::string path = "pkg_io_excl.bin"; + const std::vector mine = patternBytes(12, 3); + CHECK(writeFileExclusive(path, PayloadBuffer(mine))); + CHECK(readAll(path) == mine); + // The create IS the check: a second call cannot replace the first file's bytes. + // (this pins the refusal, not O_EXCL's atomicity — a genuine race isn't portable) + CHECK(!writeFileExclusive(path, PayloadBuffer(patternBytes(9, 8)))); + CHECK(readAll(path) == mine); + CHECK(!writeFileExclusive("pkg_io_excl_empty.bin", PayloadBuffer{})); + CHECK(!exists("pkg_io_excl_empty.bin")); + removeQuietly(path); + + // writeFileExclusive is the one call site using the wstring()/c_str() Windows + // open form rather than the fstream(fs::path) overload every other test here + // exercises — the only conversion whose reversion this file would otherwise miss. + const std::string cafePath = "pkg_io_excl_caf\xC3\xA9.bin"; + const std::vector cafeBytes = patternBytes(6, 4); + CHECK(writeFileExclusive(cafePath, PayloadBuffer(cafeBytes))); + CHECK(readAll(cafePath) == cafeBytes); + CHECK(!writeFileExclusive(cafePath, PayloadBuffer(patternBytes(3, 9)))); + removeQuietly(cafePath); +} + +static void testListFolderFileNames() { + const std::string dir = "pkg_io_listdir"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + writeScratchFile(dir + "/b.bin", patternBytes(2, 1)); + writeScratchFile(dir + "/a.bin", patternBytes(2, 2)); + fs::create_directory(utf8Path(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(utf8Path(dir), ec); +} + +int main() { + testPayloadCounterTracksMovesNotCopies(); + testStreamingRoundTripHoldsOnePayload(); + testEmptyPayloadIsRefusedAndPoisonsTheWriter(); + testAbandonedWriteLeavesNoDestination(); + testAbortPreservesPriorContents(); + testCommitReplacesAnExistingFile(); + testOpenFailureIsInert(); + testCommitRenameFailureSelfCleans(); + testReaderEdges(); + testFileStatusSeparatesAbsentFromUnreadable(); + testNonAsciiPathsRoundTripAsUtf8(); + testWriteFileExclusiveRefusesAnOccupiedPath(); + 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..6f68bfc --- /dev/null +++ b/tests/test_package_rollback.cpp @@ -0,0 +1,195 @@ +// Standalone tests for shell/package/package_rollback — no REAPER, no framework. +// Pins both halves of the deletion discriminator: the structural half (only an +// exclusively-created path is recorded, and the record is absolute so a CWD change +// cannot re-aim it) and the contract half (markIndexCommitted disarms rollback). + +#include "../src/shell/package/package_rollback.h" +#include "../src/shell/package/package_path.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) + +// The journal records absolute paths, so every expectation is built the same way. +static std::string scratch(const std::string& name) { + return pathToUtf8(fs::current_path() / utf8Path(name)); +} + +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(utf8Path(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(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } + +static void removeQuietly(const std::string& path) { + std::error_code ec; + fs::remove(utf8Path(path), ec); +} + +static void testLandRecordsOnSuccessOnly() { + LandedFileJournal journal; + const std::string path = scratch("rb_land.bin"); + const std::vector bytes = patternBytes(32, 1); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + CHECK(readAll(path) == bytes); + CHECK(journal.landedPaths().size() == 1); + CHECK(!exists(path + ".rsbanktmp")); // the land is a direct exclusive create + journal.rollback(); + CHECK(!exists(path)); // the recorded path denoted the file we asked for +} + +static void testLandNonAsciiPathRoundTripsAsUtf8() { + // The fs::absolute -> u8string round trip at writeLandedFile is otherwise + // untested with a non-ASCII path. + LandedFileJournal journal; + const std::string path = scratch("rb_caf\xC3\xA9.bin"); + const std::vector bytes = patternBytes(16, 2); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + CHECK(readAll(path) == bytes); + journal.rollback(); + CHECK(!exists(path)); +} + +static void testRelativeInputIsRecordedAbsolute() { + // The hazard: a bare name recorded verbatim, then a CWD change, and rollback + // unlinks whatever now sits at that name in the new directory. + LandedFileJournal journal; + CHECK(journal.writeLandedFile("rb_relative.bin", PayloadBuffer(patternBytes(8, 4)))); + CHECK(journal.landedPaths().size() == 1); + const std::string recorded = journal.landedPaths().front(); + CHECK(utf8Path(recorded).is_absolute()); + std::error_code ec; + // Absolute AND still the same file — a spelling check alone would not prove that. + CHECK(fs::equivalent(utf8Path(recorded), utf8Path(scratch("rb_relative.bin")), ec)); + CHECK(!ec); + journal.rollback(); + CHECK(!exists(scratch("rb_relative.bin"))); +} + +static void testExistingDestinationRefusedUntouched() { + LandedFileJournal journal; + const std::string path = scratch("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(exists(path)); // rollback cannot touch a file it did not write + removeQuietly(path); +} + +static void testEmptyPayloadRefused() { + LandedFileJournal journal; + const std::string path = scratch("rb_empty.bin"); + CHECK(!journal.writeLandedFile(path, PayloadBuffer{})); + CHECK(!exists(path)); + CHECK(journal.empty()); +} + +static void testRollbackDeletesExactlyTheRecordedSet() { + LandedFileJournal journal; + const std::string a = scratch("rb_a.bin"); + const std::string c = scratch("rb_c.bin"); + const std::string bystander = scratch("rb_bystander.bin"); + CHECK(journal.writeLandedFile(a, PayloadBuffer(patternBytes(8, 1)))); + CHECK(journal.writeLandedFile(c, PayloadBuffer(patternBytes(8, 2)))); + writeScratchFile(bystander, patternBytes(8, 3)); // not journal-written + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 2); + CHECK(result.alreadyAbsentCount == 0); + CHECK(result.failedCount == 0); + CHECK(!result.refused); + CHECK(!exists(a)); + CHECK(!exists(c)); + CHECK(exists(bystander)); // exactly the given files, nothing else + CHECK(journal.empty()); + + const RollbackResult second = journal.rollback(); // cleared: a no-op + CHECK(second.deletedCount == 0); + CHECK(exists(bystander)); + removeQuietly(bystander); +} + +static void testVanishedFileIsToleratedNotFailed() { + LandedFileJournal journal; + const std::string path = scratch("rb_gone.bin"); + CHECK(journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1)))); + removeQuietly(path); // vanished between land and rollback + CHECK(!exists(path)); + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 0); + CHECK(result.alreadyAbsentCount == 1); + CHECK(result.failedCount == 0); +} + +static void testIndexCommitDisarmsRollback() { + // Once the index references these files the carve-out no longer covers them, so a + // late failure in the verb must not be able to delete indexed bytes. + LandedFileJournal journal; + const std::string path = scratch("rb_committed.bin"); + const std::vector bytes = patternBytes(8, 1); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + + journal.markIndexCommitted(); + CHECK(journal.indexCommitted()); + + const RollbackResult result = journal.rollback(); + CHECK(result.refused); + CHECK(result.deletedCount == 0); + CHECK(readAll(path) == bytes); // untouched + CHECK(!journal.empty()); // the record survives the refusal + + // Landing more files after the commit would produce unrollbackable state. + const std::string late = scratch("rb_late.bin"); + CHECK(!journal.writeLandedFile(late, PayloadBuffer(patternBytes(8, 2)))); + CHECK(!exists(late)); + + removeQuietly(path); +} + +int main() { + testLandRecordsOnSuccessOnly(); + testLandNonAsciiPathRoundTripsAsUtf8(); + testRelativeInputIsRecordedAbsolute(); + testExistingDestinationRefusedUntouched(); + testEmptyPayloadRefused(); + testRollbackDeletesExactlyTheRecordedSet(); + testVanishedFileIsToleratedNotFailed(); + testIndexCommitDisarmsRollback(); + + 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; +}