import: a .rsbank lands as a new bank, whole or not at all
Four collisions answered explicitly: ids reminted, names never overwritten, content deduped before the write, bank name auto-suffixed. Degraded ledger refuses before the picker.
This commit is contained in:
@@ -6,11 +6,12 @@ The filesystem and dialog acts behind bank-package export/import: streaming pack
|
||||
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.
|
||||
(`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry
|
||||
layout) is `core/package`'s business. Beside them sits the import verb, split so its
|
||||
decisions stay testable: `import_landing` (REAPER-free) decides and writes,
|
||||
`import_bank` owns the only REAPER project state this directory touches (the
|
||||
ext-state persist, the undo block, the generation bump). The export verb does not
|
||||
live here yet.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -65,7 +66,14 @@ belong to the verbs.
|
||||
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.)
|
||||
`LandedFileJournal`'s own doc comment.) `import_bank` honours it: it calls
|
||||
`markIndexCommitted()` only after `persistBankOp` has returned.
|
||||
- **Integrity is proven before the first byte lands, not undone after.**
|
||||
`landPackage` hashes every declared payload against the manifest and only then
|
||||
creates the bank folder, so a damaged package costs no rollback at all and cannot
|
||||
leave debris behind a rollback that itself failed. The second read of each payload
|
||||
is deliberate on a once-per-gesture path — do not fold it into one
|
||||
hash-and-write pass.
|
||||
- **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
|
||||
@@ -77,6 +85,8 @@ belong to the verbs.
|
||||
- `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.
|
||||
- `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.
|
||||
- `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ 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 import's decisions and its file half, both REAPER-free, so all-or-nothing,
|
||||
# integrity and birth-record behaviour are assertable without a DAW. The REAPER-facing
|
||||
# verb over them (import_bank.cpp) compiles into the extension module instead.
|
||||
reasampler_pure_library(import_landing
|
||||
SOURCES import_landing.cpp
|
||||
LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec)
|
||||
reasampler_test(import_landing LINK import_landing bank_package app_version wav_codec origin_ledger)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
|
||||
// import: the project directory, the minted bank id, the birth records, and the one
|
||||
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
|
||||
//
|
||||
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
|
||||
|
||||
#include "shell/package/import_bank.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp
|
||||
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_genGuid
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string activeProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return capture::projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// The model mints no ids (it stays pure and deterministic), so the shell does — the
|
||||
// same GUID pair bankOpCreate uses.
|
||||
std::string mintBankId() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract)
|
||||
guidToString(&g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void fillPlanCounts(ImportBankResult& out, const package::ImportPlan& plan) {
|
||||
out.bankDisplayName = plan.bankDisplayName;
|
||||
out.seedBankName = plan.seedBankName;
|
||||
out.bankNameAdjusted = plan.bankNameAdjusted;
|
||||
out.landedCount = plan.landCount;
|
||||
out.renamedCount = plan.renameCount;
|
||||
out.collapsedCount = plan.collapseCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||
const std::string& packageAbsPath) {
|
||||
ImportBankResult out;
|
||||
|
||||
// A per-import disambiguator, the same shape capture and ingest file under.
|
||||
const std::string uniqueTag =
|
||||
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
|
||||
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
|
||||
session.book(), uniqueTag, journal);
|
||||
out.outcome = landing.outcome;
|
||||
out.header = landing.header;
|
||||
out.failedEntryName = landing.failedEntryName;
|
||||
out.rollback = landing.rollback;
|
||||
fillPlanCounts(out, landing.plan);
|
||||
if (landing.outcome != ImportOutcome::Landed) return out;
|
||||
|
||||
const bool applied = applyImportedBank(
|
||||
session.book(), mintBankId(), landing.plan,
|
||||
[&session](const model::Sample& s) {
|
||||
session.recordCreated(s, tracking::OriginKind::PackageImport);
|
||||
});
|
||||
if (!applied) {
|
||||
out.outcome = ImportOutcome::IndexRejected;
|
||||
out.rollback = journal.rollback();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole
|
||||
// import back out of the index. It does NOT un-write the files — the summary says so.
|
||||
persistBankOp(session, "ReaSampler: import bank package", /*bumpGeneration=*/true);
|
||||
// Only now: the files are referenced, so prune's self-cleanup carve-out no longer
|
||||
// covers them (see package_rollback.h).
|
||||
journal.markIndexCommitted();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
// shell/package/import_bank — the promptless import verb: one package becomes one NEW
|
||||
// bank in the live session, completely or not at all. No prompts, no message boxes, no
|
||||
// picker — it reports and the action skin (shell/actions/package_import_action) speaks.
|
||||
// The ledger gate is the skin's, because it must refuse BEFORE a file is even chosen.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/package/import_plan.h"
|
||||
#include "shell/package/import_landing.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
struct ImportBankResult {
|
||||
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||
package::PackageHeader header; // TooNew names the writer's build from here
|
||||
|
||||
std::string bankDisplayName; // the bank actually created
|
||||
std::string seedBankName; // what the package asked to be called
|
||||
bool bankNameAdjusted = false;
|
||||
|
||||
int landedCount = 0;
|
||||
int renamedCount = 0;
|
||||
int collapsedCount = 0;
|
||||
|
||||
std::string failedEntryName;
|
||||
RollbackResult rollback;
|
||||
};
|
||||
|
||||
// Lands `packageAbsPath` as a new bank in `session`, in ONE undo point, bumping the
|
||||
// bank generation so live instances reload. Places no timeline item. On any failure
|
||||
// nothing remains on disk and the book is untouched.
|
||||
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||
const std::string& packageAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,137 @@
|
||||
// import_landing.cpp — see import_landing.h for the contract. REAPER-free: standard
|
||||
// filesystem only, so every property this file decides is unit-testable.
|
||||
|
||||
#include "shell/package/import_landing.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/wav_codec.h" // hashBytes — the digest the manifest records
|
||||
#include "core/package/bank_package.h"
|
||||
#include "shell/package/package_io.h"
|
||||
#include "shell/package/package_path.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using package::EntryAction;
|
||||
using package::PackageEntrySpan;
|
||||
|
||||
namespace {
|
||||
|
||||
ImportLanding refusal(ImportOutcome outcome, const package::PackageHeader& header) {
|
||||
ImportLanding out;
|
||||
out.outcome = outcome;
|
||||
out.header = header;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Reads the package head incrementally: requiredPrefixSize may grow its answer as
|
||||
// fields arrive, so ask, read to the count, ask again. False means these bytes can
|
||||
// never frame a package, or the file is shorter than its own header claims.
|
||||
bool readPrefix(PackageFileReader& reader, std::vector<std::uint8_t>& prefix) {
|
||||
for (;;) {
|
||||
const auto need = package::requiredPrefixSize(prefix);
|
||||
if (!need) return false;
|
||||
if (prefix.size() >= *need) return true;
|
||||
PayloadBuffer head = reader.readRange(0, *need);
|
||||
if (head.size() != *need) return false;
|
||||
prefix.assign(head.data(), head.data() + head.size());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||
const std::string& projectDir,
|
||||
const BankBook& destination,
|
||||
const std::string& uniqueTag,
|
||||
LandedFileJournal& journal) {
|
||||
const package::PackageHeader noHeader;
|
||||
if (projectDir.empty()) return refusal(ImportOutcome::NoProject, noHeader);
|
||||
|
||||
PackageFileReader reader(packageAbsPath);
|
||||
if (!reader.ok()) return refusal(ImportOutcome::Unreadable, noHeader);
|
||||
|
||||
std::vector<std::uint8_t> prefix;
|
||||
if (!readPrefix(reader, prefix)) return refusal(ImportOutcome::Malformed, noHeader);
|
||||
|
||||
const package::DecodedPackage dec = package::decodePackage(prefix, reader.fileSize());
|
||||
if (dec.status == package::PackageReadability::TooNew)
|
||||
return refusal(ImportOutcome::TooNew, dec.header);
|
||||
if (dec.status != package::PackageReadability::Readable)
|
||||
return refusal(ImportOutcome::Malformed, dec.header);
|
||||
|
||||
const std::string bankDir = package::bankFolderDir(projectDir);
|
||||
|
||||
ImportLanding out;
|
||||
out.header = dec.header;
|
||||
out.plan = package::planImport(dec.manifest, destination, projectDir,
|
||||
listFolderFileNames(bankDir), uniqueTag);
|
||||
|
||||
// Integrity first, over EVERY declared entry — including one the plan collapses,
|
||||
// since a package that fails its own digest is refused whole rather than partly
|
||||
// trusted. Nothing is on disk yet, so a failure here needs no rollback.
|
||||
for (std::size_t i = 0; i < dec.layout.size(); ++i) {
|
||||
const PackageEntrySpan& span = dec.layout[i];
|
||||
// The format refuses a zero-length entry on encode; one arriving anyway cannot
|
||||
// be told from a failed read at this seam, so it is not well-formed input.
|
||||
if (span.length == 0) return refusal(ImportOutcome::Malformed, dec.header);
|
||||
|
||||
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||
if (payload.size() != span.length) {
|
||||
out.outcome = ImportOutcome::IntegrityFailed;
|
||||
out.failedEntryName = span.name;
|
||||
return out;
|
||||
}
|
||||
if (capture::hashBytes(payload.data(), payload.size()) !=
|
||||
dec.manifest.entries[i].byteHash) {
|
||||
out.outcome = ImportOutcome::IntegrityFailed;
|
||||
out.failedEntryName = span.name;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directories(utf8Path(bankDir), ec); // idempotent; the write reports failure
|
||||
|
||||
for (const package::PlannedEntry& e : out.plan.entries) {
|
||||
if (e.action != EntryAction::Land) continue;
|
||||
const PackageEntrySpan& span = dec.layout[e.manifestIndex];
|
||||
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||
if (payload.size() == span.length &&
|
||||
journal.writeLandedFile(bankDir + "/" + e.destFileName, payload)) {
|
||||
continue;
|
||||
}
|
||||
out.outcome = ImportOutcome::WriteFailed;
|
||||
out.failedEntryName = e.destFileName;
|
||||
out.rollback = journal.rollback();
|
||||
return out;
|
||||
}
|
||||
|
||||
out.outcome = ImportOutcome::Landed;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||
const package::ImportPlan& plan, const RecordBirth& recordBirth) {
|
||||
if (!book.createBank(bankId, plan.bankDisplayName)) return false;
|
||||
|
||||
BankModel* index = book.index(bankId);
|
||||
for (const package::PlannedEntry& e : plan.entries) {
|
||||
if (e.action != EntryAction::Land) continue;
|
||||
index->add(e.sample);
|
||||
// Unconditional on the add's outcome: the file exists either way, and an
|
||||
// unrecorded file is permanently unreclaimable.
|
||||
recordBirth(e.sample);
|
||||
}
|
||||
|
||||
book.bank(bankId)->slots = plan.slots;
|
||||
book.reconcileSlots();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
// shell/package/import_landing — the import's filesystem half and its index half,
|
||||
// both REAPER-free so the all-or-nothing, integrity and birth-record properties are
|
||||
// assertable without a DAW. The verb that drives them against a live session is
|
||||
// import_bank; the REAPER-facing reporting is shell/actions/package_import_action.
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/package/import_plan.h"
|
||||
#include "core/package/package_format.h"
|
||||
#include "shell/package/package_rollback.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// How a landing ended. Every value but Landed means NOTHING is on disk and NO index
|
||||
// was touched — the two refuse-whole failures (TooNew, Malformed) before a byte is
|
||||
// written, the other two after a rollback.
|
||||
enum class ImportOutcome {
|
||||
Landed,
|
||||
NoProject, // unsaved project: there is no bank folder to land into
|
||||
Unreadable, // the package file could not be opened
|
||||
Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage
|
||||
TooNew, // minReaderVersion above this build's ladder
|
||||
IntegrityFailed, // an entry's payload did not match its recorded digest
|
||||
WriteFailed, // a write failed partway; the landed files were rolled back
|
||||
IndexRejected, // the book refused the bank the plan minted a free name for
|
||||
};
|
||||
|
||||
struct ImportLanding {
|
||||
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||
// Meaningful from the moment the header parsed — a TooNew refusal names the
|
||||
// writer's build, which is the only part of that message a user can act on.
|
||||
package::PackageHeader header;
|
||||
package::ImportPlan plan;
|
||||
std::string failedEntryName; // IntegrityFailed / WriteFailed
|
||||
RollbackResult rollback; // IntegrityFailed / WriteFailed
|
||||
};
|
||||
|
||||
// Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY
|
||||
// payload's digest, then land. Verification runs to completion before the first write,
|
||||
// so a damaged package costs no rollback at all. Mutates no index and holds at most
|
||||
// one payload at a time. `journal` is left armed on success — the caller applies the
|
||||
// plan to the book and only then disarms it.
|
||||
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||
const std::string& projectDir,
|
||||
const BankBook& destination,
|
||||
const std::string& uniqueTag,
|
||||
LandedFileJournal& journal);
|
||||
|
||||
// Called for every landed file, in the same straight-line block as its bank add —
|
||||
// core/tracking/CLAUDE.md's no-silent-gaps invariant, kept structural by passing the
|
||||
// writer in rather than letting a caller add first and record later.
|
||||
using RecordBirth = std::function<void(const model::Sample&)>;
|
||||
|
||||
// Adds the plan's landed entries to a NEW bank under `bankId`. False (no mutation)
|
||||
// only if the book refuses the create — the name was minted free against this same
|
||||
// book, so that means the book changed underneath the plan.
|
||||
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||
const package::ImportPlan& plan, const RecordBirth& recordBirth);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user