Merge Ε-W1-T2: the package filesystem shell, pickers, and rollback journal

This commit is contained in:
2026-08-02 11:38:54 -04:00
12 changed files with 1294 additions and 0 deletions
+1
View File
@@ -92,3 +92,4 @@ enable_testing()
add_subdirectory(src/core)
add_subdirectory(src/app)
add_subdirectory(src/shell/instrument)
add_subdirectory(src/shell/package)
+112
View File
@@ -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.
+20
View File
@@ -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()
+268
View File
@@ -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 <algorithm>
#include <atomic>
#include <limits>
#include <utility>
#include "shell/package/package_path.h"
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#include <share.h>
#include <sys/stat.h>
#else
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#endif
namespace reasampler {
namespace fs = std::filesystem;
namespace {
std::atomic<int> g_alivePayloads{0};
}
// ---------------------------------------------------------------------------
// PayloadBuffer
PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> 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<bool>(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<const char*>(data),
static_cast<std::streamsize>(len));
ok_ = static_cast<bool>(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<bool>(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<std::uint64_t>(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::uint64_t>(std::numeric_limits<std::size_t>::max())) {
return PayloadBuffer{};
}
in_.clear(); // a prior failed read must not poison this one
in_.seekg(static_cast<std::streamoff>(offset));
if (!in_) return PayloadBuffer{};
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(length));
in_.read(reinterpret_cast<char*>(bytes.data()),
static_cast<std::streamsize>(length));
if (static_cast<std::uint64_t>(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<std::size_t>(payload.size() - written, 1u << 20);
#ifdef _WIN32
const int n = _write(fd, payload.data() + written,
static_cast<unsigned int>(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<std::size_t>(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<std::string> listFolderFileNames(const std::string& dirAbsPath) {
std::vector<std::string> 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
+127
View File
@@ -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 <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
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<std::uint8_t> 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<std::uint8_t> 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<std::string> listFolderFileNames(const std::string& dirAbsPath);
} // namespace reasampler
+26
View File
@@ -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 <filesystem>
#include <string>
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
+63
View File
@@ -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 <algorithm>
#include <cctype>
#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<int>(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
+24
View File
@@ -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 <string>
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
+51
View File
@@ -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 <filesystem>
#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
+59
View File
@@ -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 <string>
#include <vector>
#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<std::string>& landedPaths() const { return paths_; }
bool empty() const { return paths_.empty(); }
private:
std::vector<std::string> paths_;
bool indexCommitted_ = false;
};
} // namespace reasampler
+348
View File
@@ -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 <algorithm>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
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<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
std::vector<std::uint8_t> v(n);
for (std::size_t i = 0; i < n; ++i)
v[i] = static_cast<std::uint8_t>(seed + i * 7u);
return v;
}
static bool sameBytes(const PayloadBuffer& p, const std::vector<std::uint8_t>& 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<std::uint8_t>& bytes) {
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
}
static std::vector<std::uint8_t> readAll(const std::string& path) {
std::ifstream f(utf8Path(path), std::ios::binary);
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
}
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<std::uint8_t> header = patternBytes(16, 0xA0);
const std::vector<std::vector<std::uint8_t>> entries = {
patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)};
std::vector<std::string> 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<std::pair<std::uint64_t, std::uint64_t>> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> prior = patternBytes(32, 0x40);
writeScratchFile(dest, prior);
{
PackageFileWriter writer(dest);
const std::vector<std::uint8_t> 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<std::uint8_t> prior = patternBytes(40, 0x11);
const std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>(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<std::uint8_t> 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<std::string>{name}));
fs::remove_all(utf8Path(dir), ec);
}
static void testWriteFileExclusiveRefusesAnOccupiedPath() {
const std::string path = "pkg_io_excl.bin";
const std::vector<std::uint8_t> 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<std::uint8_t> 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<std::string> names = listFolderFileNames(dir);
CHECK(names == (std::vector<std::string>{"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;
}
+195
View File
@@ -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 <algorithm>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
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<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
std::vector<std::uint8_t> v(n);
for (std::size_t i = 0; i < n; ++i)
v[i] = static_cast<std::uint8_t>(seed + i * 7u);
return v;
}
static void writeScratchFile(const std::string& path,
const std::vector<std::uint8_t>& bytes) {
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
}
static std::vector<std::uint8_t> readAll(const std::string& path) {
std::ifstream f(utf8Path(path), std::ios::binary);
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
}
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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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;
}