Merge Ε-W2: bank export and bank import, both verbs and both panel rows
Union of two parallel tracks. Both action rows, both menu rows, both link edges survive; the two package CLAUDE.md files now describe the post-merge reality rather than either side's pre-merge scope.
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "core/util/ascii_ws.h"
|
||||
|
||||
// bank_book implementation — the registry RULES half: construction, pool
|
||||
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
|
||||
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
|
||||
@@ -76,9 +78,8 @@ void BankBook::normalizeOrdinals() {
|
||||
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
|
||||
std::string BankBook::nameKey(const std::string& s) {
|
||||
std::size_t b = 0, e = s.size();
|
||||
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
||||
while (b < e && isWs(s[b])) ++b;
|
||||
while (e > b && isWs(s[e - 1])) --e;
|
||||
while (b < e && util::isAsciiWs(s[b])) ++b;
|
||||
while (e > b && util::isAsciiWs(s[e - 1])) --e;
|
||||
std::string out;
|
||||
out.reserve(e - b);
|
||||
for (std::size_t i = b; i < e; ++i) {
|
||||
@@ -117,6 +118,17 @@ bool BankBook::createBank(const std::string& id, const std::string& displayName)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runs behind displayNameTaken so the probe and the create/rename check can never
|
||||
// disagree about what "already used" means. exceptId is deliberately "" — no bank can
|
||||
// carry an empty id, so nothing is excluded from the scan.
|
||||
std::string BankBook::uniqueDisplayName(const std::string& seed) const {
|
||||
if (!displayNameTaken(seed, /*exceptId=*/std::string{})) return seed;
|
||||
for (int n = 2;; ++n) {
|
||||
std::string candidate = seed + " " + std::to_string(n);
|
||||
if (!displayNameTaken(candidate, /*exceptId=*/std::string{})) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
|
||||
if (id == kPoolBankId) return false; // pool is un-renamable
|
||||
Bank* b = bank(id);
|
||||
|
||||
@@ -100,6 +100,17 @@ public:
|
||||
// no-op success.
|
||||
bool renameBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// The first name in the sequence `seed`, "seed 2", "seed 3", … whose fold is free
|
||||
// in this book — what a caller that must not be rejected (the package import) asks
|
||||
// for before createBank. First-FREE-ascending, not highest-plus-one, so it fills a
|
||||
// gap ("Drums" + "Drums 3" present yields "Drums 2") and is a pure function of the
|
||||
// current name set. The seed is returned verbatim when free and is NEVER re-parsed:
|
||||
// a bare trailing integer cannot be told from a user's own name, so "Kit 808" would
|
||||
// become "Kit 2" under a stripping rule. Terminates by pigeonhole (one of the first
|
||||
// N+1 candidates is free for N banks), so it needs no cap. A blank seed comes back
|
||||
// blank — what a missing name should become is the caller's policy, not the model's.
|
||||
std::string uniqueDisplayName(const std::string& seed) const;
|
||||
|
||||
// Deletes a named bank and its member entries (files untouched — a shell/prune
|
||||
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
|
||||
// ordinals compact after; if the deleted bank was active, falls back to the pool.
|
||||
|
||||
+32
-10
@@ -7,7 +7,7 @@ unit-tested outside the DAW): the format contract and version ladder, the JSON
|
||||
manifest, and the framing/layout codec. No filesystem — the shell
|
||||
(`src/shell/package`) streams bytes against the layouts produced here. The
|
||||
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
||||
`export_plan` has landed, `import_plan` has not.
|
||||
both have landed.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -79,6 +79,14 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
||||
(missing / unreadable / an index record the format cannot represent). Owns the
|
||||
name repair the codec's refusal backstops, and normalizes each shipping record's
|
||||
`relativePath` to the bare package name — see the transport-name gotcha below.
|
||||
- `import_plan` — the pure import decision, and the reason the whole feature is
|
||||
testable without a DAW: the destination bank's display name after
|
||||
`BankBook`'s own fold, the reminted sample ids and remapped parents, and the
|
||||
per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the
|
||||
import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and
|
||||
`ledgerRefusalMessage` (the gate's console-block body, a pure
|
||||
`(LedgerRefusal, namespace) -> string` fold the shell only supplies the
|
||||
channel-correct namespace to).
|
||||
- `bank_package` — framing and arithmetic composing the two above:
|
||||
`encodePackage` (prefix bytes + layout + total size, stamping this build's
|
||||
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
|
||||
@@ -97,6 +105,11 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
||||
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
|
||||
codec rejects only what makes the container itself incoherent (duplicate
|
||||
entry names, invalid names, a non-single-sample nested index).
|
||||
- **`import_plan` consults no other bank's hashes, and that is the ruling, not
|
||||
an omission.** An import always creates a NEW bank, so "already present in the
|
||||
destination bank by content" is exactly "already landed by this same plan".
|
||||
Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash
|
||||
the pool already holds still lands its own file here.
|
||||
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
|
||||
version pair classifies `Readable`; for `TooNew` it stops at the semver —
|
||||
don't "fix" it to read the manifest length there, a future structural format
|
||||
@@ -155,18 +168,27 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
||||
collision class as the ASCII case fold, which `sameEntryName` does catch. A
|
||||
table-free fix does not exist, and restricting names to ASCII would be
|
||||
genuinely over-strict for non-English users. Left open knowingly.
|
||||
- **`duplicateName` is O(n²) over `entries` on the decode path** — pre-existing
|
||||
shape (the double loop is unchanged since `af35fc5`; only the comparator
|
||||
changed). Under the `kMaxManifestBytes` cap (64 MB) a minimal entry is
|
||||
~100 bytes, so a hostile package can declare ~670k entries — ~2×10¹¹ pair
|
||||
comparisons, a multi-minute hang on import. It signals an error rather than
|
||||
UB, so the hostile-input invariant above still holds, but it sits against
|
||||
this module's "a forged header cannot demand gigabytes" posture. Forward
|
||||
obligation for `import_plan`: fold this into a sorted vector or hash set
|
||||
when that track lands; not changed here.
|
||||
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
|
||||
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
|
||||
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
|
||||
comparisons — a multi-minute hang on the decode path an import drives. The
|
||||
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
|
||||
made explicit, so the equivalence rule still has one home (`lowerAscii`).
|
||||
Do not reintroduce the pairwise scan.
|
||||
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
|
||||
entry cannot round-trip through the filesystem seam there (`appendPayload`
|
||||
refuses an empty payload — an empty buffer signals an upstream read failure,
|
||||
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
|
||||
at encode so this layer never produces one; decode does not enforce it (a
|
||||
hostile/older package declaring one is not this track's concern).
|
||||
- **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO
|
||||
collision, and that third condition is a deliberate decision, not spec-derived.**
|
||||
`docs/product/bank-package.md:447` ties the auto-rename mint to a *collision*
|
||||
only; `spelledLikeABankFile` additionally mints whenever the package's own name
|
||||
isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem).
|
||||
Kept for two reasons: uniform folder spelling for every landed file regardless of
|
||||
origin, and — the sharper one — a hostile entry name that isn't a legal Windows
|
||||
filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized
|
||||
(`evil_<tag>.wav`) rather than verbatim. `ImportPlan` counts this separately from a
|
||||
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
|
||||
the summary line means what `bank-package.md:447` says it means.
|
||||
|
||||
@@ -17,3 +17,10 @@ reasampler_pure_library(bank_package
|
||||
LINK PUBLIC package_format package_manifest PRIVATE app_version)
|
||||
# app_version: the tests pin the stamped writer semver against stampVersion().
|
||||
reasampler_test(bank_package LINK bank_package app_version)
|
||||
|
||||
reasampler_pure_library(import_plan
|
||||
SOURCES import_plan.cpp
|
||||
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
|
||||
# tracking_authority: the ledger-gate test proves the import does NOT share prune's
|
||||
# composite blocker, which needs the composite to compare against.
|
||||
reasampler_test(import_plan LINK import_plan tracking_authority)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "core/package/import_plan.h"
|
||||
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/package/package_format.h"
|
||||
#include "core/util/ascii_ws.h"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
namespace {
|
||||
|
||||
using capture::bankRelativeForName;
|
||||
using capture::deriveBankPaths;
|
||||
using capture::sanitizeStem;
|
||||
using util::isAsciiWs;
|
||||
|
||||
// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
|
||||
// would fold to empty is never treated as recorded here.
|
||||
bool blankName(const std::string& s) {
|
||||
for (char c : s)
|
||||
if (!isAsciiWs(c)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// "kick.wav" -> "kick"; a name with no dot is its own stem. deriveBankPaths re-adds
|
||||
// the extension, so handing it the full name would file "kick.wav" as "kick.wav.wav".
|
||||
std::string stemOf(const std::string& fileName) {
|
||||
const std::size_t dot = fileName.rfind('.');
|
||||
if (dot == std::string::npos || dot == 0) return fileName;
|
||||
return fileName.substr(0, dot);
|
||||
}
|
||||
|
||||
// True when `fileName` is already spelled the way this tool spells a bank file, so a
|
||||
// package landing in a fresh project keeps the names it travelled with. Anything else
|
||||
// is minted through deriveBankPaths, which is also the sanitizer.
|
||||
bool spelledLikeABankFile(const std::string& fileName) {
|
||||
const std::string stem = stemOf(fileName);
|
||||
return stem != fileName && sanitizeStem(stem) == stem && fileName == stem + ".wav";
|
||||
}
|
||||
|
||||
// The bank-folder names an import must not land on: what is there already, plus what
|
||||
// this import has minted so far. Case-folded, because the two filesystems this tool
|
||||
// ships on would treat "Kick.wav" and "kick.wav" as one file.
|
||||
//
|
||||
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
|
||||
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
|
||||
// invisible here. The plan then never mints around it, and the later exclusive-create
|
||||
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
|
||||
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
|
||||
// traces back to a same-named folder in the bank directory; test_import_landing's
|
||||
// rollback suite deliberately exploits this to exercise the rollback path.
|
||||
class NameSet {
|
||||
public:
|
||||
explicit NameSet(const std::vector<std::string>& present) {
|
||||
keys_.reserve(present.size());
|
||||
for (const std::string& n : present) keys_.insert(entryNameKey(n));
|
||||
}
|
||||
bool taken(const std::string& name) const { return keys_.count(entryNameKey(name)) != 0; }
|
||||
void claim(const std::string& name) { keys_.insert(entryNameKey(name)); }
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> keys_;
|
||||
};
|
||||
|
||||
// The name this entry lands under. Terminates: each attempt carries a distinct
|
||||
// counter, and the taken set is finite.
|
||||
std::string mintFileName(const std::string& projectDir, const std::string& packageName,
|
||||
const std::string& uniqueTag, const NameSet& taken) {
|
||||
if (spelledLikeABankFile(packageName) && !taken.taken(packageName)) return packageName;
|
||||
|
||||
const std::string stem = stemOf(packageName);
|
||||
std::string tag = uniqueTag;
|
||||
for (int n = 2;; ++n) {
|
||||
const std::string candidate = deriveBankPaths(projectDir, stem, tag).fileName;
|
||||
if (!taken.taken(candidate)) return candidate;
|
||||
tag = uniqueTag + "-" + std::to_string(n);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
|
||||
// Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
|
||||
// re-deriving it from the two named statuses, so a future degraded status added
|
||||
// there is refused here too rather than silently falling through to None.
|
||||
if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
|
||||
// Below this point status is known degraded; only the message variant is picked.
|
||||
// Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
|
||||
// status (today only FutureVersion) gets the "written by a newer build" wording.
|
||||
return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
|
||||
: LedgerRefusal::FutureVersion;
|
||||
}
|
||||
|
||||
// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
|
||||
// because a user who has hit that one should recognise this one. Every recovery line
|
||||
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
|
||||
// key and is still blocked.
|
||||
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
|
||||
if (refusal == LedgerRefusal::None) return {};
|
||||
|
||||
std::string msg =
|
||||
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
|
||||
"Nothing was imported.\n";
|
||||
if (refusal == LedgerRefusal::Malformed) {
|
||||
msg += "The stored file-tracking ledger is malformed. It has been left intact "
|
||||
"rather than overwritten, so it can be repaired or cleared:\n"
|
||||
" reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
|
||||
"Clearing it makes every existing bank file un-reclaimable (they stop "
|
||||
"being attributable to ReaSampler); no file is lost. Reopen the project "
|
||||
"afterwards -- the block is held for the rest of this session.\n";
|
||||
} else {
|
||||
msg += "The stored file-tracking ledger was written by a NEWER version of "
|
||||
"ReaSampler than this one, so its records cannot be read safely. It has "
|
||||
"been left intact and will NOT be overwritten. Reopen the project with "
|
||||
"that newer version -- do NOT clear this key from here, that would "
|
||||
"discard tracking records this build cannot see. The block is held for "
|
||||
"the rest of this session.\n";
|
||||
}
|
||||
msg += "An import can land hundreds of files in one gesture. With no readable "
|
||||
"ledger, none of them could be given a birth record, and every one would be "
|
||||
"permanently unreclaimable.\n";
|
||||
return msg;
|
||||
}
|
||||
|
||||
std::string bankFolderDir(const std::string& projectDir) {
|
||||
// Only the directory half of the result is wanted; the stem is a placeholder.
|
||||
return deriveBankPaths(projectDir, "bank", std::string{}).absoluteDir;
|
||||
}
|
||||
|
||||
ImportPlan planImport(const PackageManifest& manifest,
|
||||
const BankBook& destination,
|
||||
const std::string& projectDir,
|
||||
const std::vector<std::string>& bankFolderFileNames,
|
||||
const std::string& uniqueTag) {
|
||||
ImportPlan plan;
|
||||
|
||||
plan.seedBankName = blankName(manifest.bankDisplayName)
|
||||
? std::string(kDefaultImportBankName)
|
||||
: manifest.bankDisplayName;
|
||||
plan.bankDisplayName = destination.uniqueDisplayName(plan.seedBankName);
|
||||
plan.bankNameAdjusted = plan.bankDisplayName != plan.seedBankName;
|
||||
|
||||
NameSet taken(bankFolderFileNames);
|
||||
// The destination bank is created empty by this same import, so "already in the
|
||||
// destination bank by content" is exactly "already landed by this plan" — the
|
||||
// hash set below IS that bank's findByHash. Cross-bank dedup is deliberately not
|
||||
// enforced (core/model/CLAUDE.md), so other banks' hashes are not consulted.
|
||||
std::unordered_map<std::string, std::string> landedIdForHash;
|
||||
// Every package id, including a collapsed one's, so a parent link that pointed at
|
||||
// a duplicate still resolves to the entry that survived it.
|
||||
std::unordered_map<std::string, std::string> idRemap;
|
||||
|
||||
plan.entries.reserve(manifest.entries.size());
|
||||
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
|
||||
const PackageEntry& src = manifest.entries[i];
|
||||
|
||||
PlannedEntry e;
|
||||
e.manifestIndex = i;
|
||||
|
||||
const std::string& hash = src.sample.contentHash;
|
||||
if (!hash.empty()) {
|
||||
const auto hit = landedIdForHash.find(hash);
|
||||
if (hit != landedIdForHash.end()) {
|
||||
e.action = EntryAction::Collapse;
|
||||
idRemap[src.sample.id] = hit->second;
|
||||
++plan.collapseCount;
|
||||
plan.entries.push_back(std::move(e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
e.destFileName = mintFileName(projectDir, src.fileName, uniqueTag, taken);
|
||||
e.renamed = e.destFileName != src.fileName;
|
||||
taken.claim(e.destFileName);
|
||||
|
||||
e.sample = src.sample;
|
||||
e.sample.id = std::string(kImportIdPrefix) + uniqueTag + "-" + e.destFileName;
|
||||
e.sample.relativePath = bankRelativeForName(e.destFileName);
|
||||
|
||||
if (!hash.empty()) landedIdForHash.emplace(hash, e.sample.id);
|
||||
idRemap[src.sample.id] = e.sample.id;
|
||||
|
||||
++plan.landCount;
|
||||
// A rename happens for one of two reasons: the package's own name was already
|
||||
// taken (spelledLikeABankFile true but the mint's fast path lost the race to
|
||||
// `taken`), or the name never qualified for that fast path at all (sanitize).
|
||||
if (e.renamed) {
|
||||
if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
|
||||
else ++plan.sanitizeRenameCount;
|
||||
}
|
||||
plan.entries.push_back(std::move(e));
|
||||
}
|
||||
|
||||
// Second pass: the remap must be complete before a parent is resolved, since a
|
||||
// sample may precede its own parent in manifest order.
|
||||
for (PlannedEntry& e : plan.entries) {
|
||||
if (e.action != EntryAction::Land || !e.sample.provenance) continue;
|
||||
const auto hit = idRemap.find(e.sample.provenance->parentSampleId);
|
||||
e.sample.provenance->parentSampleId =
|
||||
hit == idRemap.end() ? std::string{} : hit->second;
|
||||
}
|
||||
|
||||
// The package's display order, over the ids that actually landed. SlotMap's own
|
||||
// repair rules settle the rest: two package ids collapsed onto one landed id give
|
||||
// one slot (first wins), and a landed sample the package never positioned is
|
||||
// appended by BankBook::reconcileSlots afterwards.
|
||||
std::vector<std::pair<std::string, int>> slotPairs;
|
||||
for (const std::string& oldId : manifest.slots.orderedIds()) {
|
||||
const auto hit = idRemap.find(oldId);
|
||||
if (hit == idRemap.end()) continue;
|
||||
slotPairs.emplace_back(hit->second, manifest.slots.slotOf(oldId));
|
||||
}
|
||||
plan.slots = model::SlotMap::fromEntries(slotPairs);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
// import_plan — the pure import decision: the destination bank's display name after
|
||||
// the book's own uniqueness fold, the reminted sample ids and remapped parents, and
|
||||
// the per-entry land / collapse / rename disposition. Value inputs only; no
|
||||
// filesystem, no session handle, no host types.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/slot_map.h"
|
||||
#include "core/package/package_manifest.h"
|
||||
#include "core/tracking/origin_ledger.h"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
// The bank name a package that recorded none (or a blank one) imports under.
|
||||
inline constexpr const char* kDefaultImportBankName = "Imported bank";
|
||||
|
||||
// The prefix every imported sample id is reminted under, so a package's own ids —
|
||||
// unique only within the project that made them — never enter this index.
|
||||
inline constexpr const char* kImportIdPrefix = "pkg-";
|
||||
|
||||
// Which of the two refusal messages the import owes the user, if either.
|
||||
//
|
||||
// Keyed on the LEDGER STATUS ALONE, never on prune's composite blockedByTracking:
|
||||
// that flag also fires on undecodable rsusage_* keys, which govern which files a
|
||||
// DELETION may touch. An import deletes nothing and computes no protected set — it
|
||||
// writes birth records — so an unreadable usage key must not refuse one.
|
||||
enum class LedgerRefusal { None, Malformed, FutureVersion };
|
||||
|
||||
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status);
|
||||
|
||||
// The console-block body for a refusal — a pure (LedgerRefusal, namespace) -> string
|
||||
// fold, so the wording is assertable without a DAW. `extStateNamespace` is the
|
||||
// channel-correct namespace (`version::extStateNamespace()`) every recovery line must
|
||||
// name, so a beta user is never handed the stable spelling. Empty string for None —
|
||||
// callers only reach this once `importLedgerRefusal` has already returned a refusal.
|
||||
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace);
|
||||
|
||||
// What one manifest entry does when the import runs.
|
||||
// - Land: write the payload under destFileName and add `sample`.
|
||||
// - Collapse: an equal contentHash already lands in this same import, so the payload
|
||||
// is NOT written and no entry is added. Writing it and letting
|
||||
// BankModel::add collapse the entry would leave the file referenced by
|
||||
// nothing — an orphan manufactured by a dedup.
|
||||
enum class EntryAction { Land, Collapse };
|
||||
|
||||
struct PlannedEntry {
|
||||
std::size_t manifestIndex = 0;
|
||||
EntryAction action = EntryAction::Land;
|
||||
std::string destFileName; // Land only — a bare name in the bank folder
|
||||
model::Sample sample; // Land only — id, path and parent already remapped
|
||||
bool renamed = false; // the package's own name was taken, so a fresh one was minted
|
||||
};
|
||||
|
||||
struct ImportPlan {
|
||||
std::string bankDisplayName;
|
||||
bool bankNameAdjusted = false; // the seed was taken, so the name carries a suffix
|
||||
std::string seedBankName; // the seed the probe started from
|
||||
std::vector<PlannedEntry> entries; // one per manifest entry, in manifest order
|
||||
model::SlotMap slots; // the package's slots over the reminted ids
|
||||
int landCount = 0;
|
||||
int collapseCount = 0;
|
||||
// Two distinct triggers, counted separately (bank-package.md:447 defines the first
|
||||
// as THE collision counter; conflating the second into it would misreport a mint
|
||||
// that never collided as a collision).
|
||||
int collisionRenameCount = 0; // the package's own name was already taken in the bank folder
|
||||
int sanitizeRenameCount = 0; // the package's name was not spelled the way this tool spells
|
||||
// a bank file (see spelledLikeABankFile, core/package/CLAUDE.md)
|
||||
};
|
||||
|
||||
// The bank folder an import lands into — the same expression capture uses, so an
|
||||
// imported file is spelled exactly like a captured one.
|
||||
std::string bankFolderDir(const std::string& projectDir);
|
||||
|
||||
// Decides everything about an import except the bytes. `bankFolderFileNames` are the
|
||||
// bare names already present in that folder (never overwritten); `uniqueTag` is the
|
||||
// shell's per-import disambiguator, extended with an ascending counter where one tag
|
||||
// is not enough. Total: every manifest entry yields exactly one PlannedEntry.
|
||||
ImportPlan planImport(const PackageManifest& manifest,
|
||||
const BankBook& destination,
|
||||
const std::string& projectDir,
|
||||
const std::vector<std::string>& bankFolderFileNames,
|
||||
const std::string& uniqueTag);
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -103,6 +103,13 @@ bool sameEntryName(const std::string& a, const std::string& b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string entryNameKey(const std::string& name) {
|
||||
std::string key;
|
||||
key.reserve(name.size());
|
||||
for (unsigned char c : name) key += lowerAscii(c);
|
||||
return key;
|
||||
}
|
||||
|
||||
bool isValidNestedSamplePath(const std::string& path) {
|
||||
if (util::isAbsolutePath(path)) return false;
|
||||
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
|
||||
|
||||
@@ -76,6 +76,12 @@ bool isValidEntryName(const std::string& name);
|
||||
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
|
||||
bool sameEntryName(const std::string& a, const std::string& b);
|
||||
|
||||
// sameEntryName's fold made explicit: the ASCII-lower-cased bytes, so
|
||||
// entryNameKey(a) == entryNameKey(b) exactly when sameEntryName(a, b). For a caller
|
||||
// holding many names at once — folding them into a set is what turns an O(n^2)
|
||||
// pairwise scan into a linear one.
|
||||
std::string entryNameKey(const std::string& name);
|
||||
|
||||
// The one field in the format that CAN express a path: a nested Sample's
|
||||
// relativePath, which is bank-relative by design. Rejects every absolute form
|
||||
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "core/package/package_manifest.h"
|
||||
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
#include "core/json/json.h"
|
||||
@@ -14,11 +15,16 @@ using ObjWriter = json::Writer;
|
||||
|
||||
// Shared by serializeManifest and deserializeManifest — see this directory's
|
||||
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
|
||||
// format's, not std::string's: sameEntryName folds ASCII case.
|
||||
// format's, not std::string's: entryNameKey is sameEntryName's ASCII-case fold.
|
||||
//
|
||||
// A set, not the pairwise scan this replaced: under kMaxManifestBytes a hostile
|
||||
// package can declare hundreds of thousands of minimal entries, and O(n^2) over that
|
||||
// is a multi-minute hang on the decode path an import drives.
|
||||
bool duplicateName(const std::vector<PackageEntry>& entries) {
|
||||
for (std::size_t i = 0; i < entries.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < entries.size(); ++j)
|
||||
if (sameEntryName(entries[i].fileName, entries[j].fileName)) return true;
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(entries.size());
|
||||
for (const auto& e : entries)
|
||||
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
## Scope
|
||||
|
||||
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
|
||||
loading, unit-interval clamping, the absolute-path rejection test, and the
|
||||
per-segment envelope curve law.
|
||||
loading, unit-interval clamping, the absolute-path rejection test, the
|
||||
per-segment envelope curve law, and the ASCII-whitespace fold test.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -19,6 +19,7 @@ per-segment envelope curve law.
|
||||
before curves existed play unchanged, and what the knob law's centre detent exists to keep
|
||||
reachable from the dial.
|
||||
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
|
||||
- `ascii_ws` (`core/util`, header-only) — the ONE ASCII-whitespace test (space/tab/CR/LF) behind `BankBook::nameKey`'s trim, shared by `core/package/import_plan`'s blank-bank-name fallback. Same rationale as `relative_path`: two independently-maintained copies could drift on what counts as blank.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
// ascii_ws — the ONE ASCII-whitespace test shared by every fold that must agree with
|
||||
// BankBook::nameKey's trim (space/tab/CR/LF): a drifted second copy could accept a
|
||||
// package bank name nameKey would treat as blank, or vice versa.
|
||||
|
||||
namespace reasampler::util {
|
||||
|
||||
inline bool isAsciiWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
|
||||
|
||||
} // namespace reasampler::util
|
||||
Reference in New Issue
Block a user