Land src/core/package: the pure RSBK container — format ladder, JSON manifest, framing/layout codec
Two-integer ladder (formatVersion/minReaderVersion), bare-name-only entries validated on encode and decode, prefix decode that proves exact file size without ever reading a payload.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# src/core/package — the pure RSBK bank-package codec
|
||||
|
||||
## Scope
|
||||
|
||||
The hand-rolled `RSBK` bank-package container, entirely pure (REAPER-free,
|
||||
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
|
||||
landing after the format.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The container is the proprietary `RSBK` — ruled, not revisitable here.** No
|
||||
ZIP, no compressor, no link edge to `vendor/WDL/WDL/zlib/`. The version
|
||||
ladder, not a format swap, is how the format moves.
|
||||
- **Two version integers, two jobs.** `formatVersion` = what the writer
|
||||
emitted; `minReaderVersion` = the oldest reader that can read it safely. The
|
||||
reader's whole rule is `minReaderVersion <= kPackageFormatVersion`. Additive
|
||||
changes (a new optional manifest key, a new enum value with a defined
|
||||
degrade) bump `formatVersion` only; structural changes bump both. The full
|
||||
ladder lives as a comment in `package_format.h` and is READ and validated,
|
||||
never merely written.
|
||||
- **TooNew refuses whole.** A `minReaderVersion` above this build yields the
|
||||
header (so the refusal can name the writer's semver and version) and nothing
|
||||
else — no manifest, no layout, no half-success. The fields through the writer
|
||||
semver are FROZEN for all future versions to keep that refusal producible.
|
||||
- **Path expression is structurally impossible.** Entry names are bare file
|
||||
names (`isValidEntryName`: no separators, no `..`, no drive/UNC/rooted form),
|
||||
enforced on encode AND decode because a package can arrive from anywhere.
|
||||
There is no field in the format capable of expressing a path.
|
||||
- **Framing only, never a payload.** `bank_package` produces header bytes and
|
||||
an ordered `{name, offset, length}` layout; it never holds, copies, or hashes
|
||||
an entry's audio. `decodePackage` proves prefix + payload lengths equal the
|
||||
observed file size exactly, so truncation and trailing garbage are Malformed
|
||||
without any payload being read.
|
||||
- **Per-sample shape has one owner.** Each entry nests a one-sample `BankModel`
|
||||
blob emitted/parsed by `bank_model`'s own codec (the `bank_book_json`
|
||||
precedent), so a future `Sample` field reaches packages with no change here.
|
||||
- **Hostile input: error signaled, never UB** — the `bank_model.h` deserialize
|
||||
standard, plus allocation caps on every length field so a forged header
|
||||
cannot demand gigabytes.
|
||||
|
||||
## Modules
|
||||
|
||||
- `package_format` — the contract: magic, `kPackageFormatVersion` /
|
||||
`kPackageMinReaderVersion`, the ladder comment, the three-way
|
||||
`classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the
|
||||
entry-name rule, and `PackageHeader`.
|
||||
- `package_manifest` — the manifest model (`PackageEntry` / `PackageManifest`)
|
||||
and its JSON codec. Per entry: bare name, byte length, and a whole-file
|
||||
`capture::hashBytes` digest (deliberately NOT `hashWavContent`, which skips
|
||||
chunks and cannot answer "did these bytes survive") — the digest is carried
|
||||
here, computed where payloads are streamed (shell). The bank's `slot_map`
|
||||
rides along. Unknown keys skip at every level; duplicate entry names are
|
||||
rejected both ways.
|
||||
- `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
|
||||
file size in; header/manifest/layout out), and `requiredPrefixSize` (the
|
||||
incremental-read seam for the shell). Framing rides `core/wire/bytes.h`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an
|
||||
out-of-range `sourceMode`/`tier` REJECTS the parse — so growing one of those
|
||||
vocabularies is a `minReaderVersion` bump for packages, not an additive
|
||||
change. Any enum integer the manifest itself ever adds must instead follow
|
||||
the degrade-to-`Unknown` rule (`core/wire`'s `BakeStatus` precedent) to stay
|
||||
additive. The manifest carries no enum of its own today.
|
||||
- Sample-id rules (remap, collision, dedup across the destination) are
|
||||
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).
|
||||
- `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
|
||||
may have moved it.
|
||||
@@ -0,0 +1,14 @@
|
||||
reasampler_pure_library(package_format SOURCES package_format.cpp)
|
||||
reasampler_test(package_format LINK package_format)
|
||||
|
||||
reasampler_pure_library(package_manifest
|
||||
SOURCES package_manifest.cpp
|
||||
LINK PUBLIC bank_model slot_map PRIVATE package_format json)
|
||||
reasampler_test(package_manifest LINK package_manifest)
|
||||
|
||||
# bytes.h is header-only (see src/core/wire/CLAUDE.md) — no wire link edge needed.
|
||||
reasampler_pure_library(bank_package
|
||||
SOURCES bank_package.cpp
|
||||
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)
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "core/package/bank_package.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/wire/bytes.h"
|
||||
|
||||
// Byte offsets (format 1, see package_format.h's ladder): magic at 0, u32
|
||||
// formatVersion at 4, u32 minReaderVersion at 8, u32 semver length W at 12,
|
||||
// semver at 16, u32 manifest length M at 16+W, manifest at 20+W, payloads at
|
||||
// 20+W+M. The region through the semver is the FROZEN refusal surface.
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMagicBytes = 4;
|
||||
constexpr std::size_t kSemverLenAt = 12;
|
||||
constexpr std::size_t kSemverAt = 16;
|
||||
|
||||
bool magicMatches(const std::vector<std::uint8_t>& bytes) {
|
||||
return bytes.size() >= kMagicBytes &&
|
||||
std::memcmp(bytes.data(), kPackageMagic, kMagicBytes) == 0;
|
||||
}
|
||||
|
||||
std::uint32_t u32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
|
||||
std::uint32_t v = 0;
|
||||
for (std::size_t b = 0; b < 4; ++b)
|
||||
v |= static_cast<std::uint32_t>(bytes[at + b]) << (b * 8);
|
||||
return v;
|
||||
}
|
||||
|
||||
// Appends the payload spans for `entries` starting at `firstOffset`. False on
|
||||
// u64 overflow (a forged length field summing past 2^64 must not wrap into a
|
||||
// plausible layout).
|
||||
bool appendSpans(const std::vector<PackageEntry>& entries, std::uint64_t firstOffset,
|
||||
std::vector<PackageEntrySpan>& out, std::uint64_t& end) {
|
||||
std::uint64_t offset = firstOffset;
|
||||
for (const auto& e : entries) {
|
||||
out.push_back({e.fileName, offset, e.byteLength});
|
||||
if (offset + e.byteLength < offset) return false;
|
||||
offset += e.byteLength;
|
||||
}
|
||||
end = offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<EncodedPackage> encodePackage(const PackageManifest& m) {
|
||||
auto manifestJson = serializeManifest(m);
|
||||
if (!manifestJson) return std::nullopt;
|
||||
if (manifestJson->size() > kMaxManifestBytes) return std::nullopt;
|
||||
|
||||
const std::string& writer = version::stampVersion();
|
||||
if (writer.size() > kMaxWriterVersionBytes) return std::nullopt;
|
||||
|
||||
EncodedPackage enc;
|
||||
auto& out = enc.prefix;
|
||||
out.insert(out.end(), kPackageMagic, kPackageMagic + kMagicBytes);
|
||||
wire::putLE(out, kPackageFormatVersion);
|
||||
wire::putLE(out, kPackageMinReaderVersion);
|
||||
wire::putLE(out, static_cast<std::uint32_t>(writer.size()));
|
||||
out.insert(out.end(), writer.begin(), writer.end());
|
||||
wire::putLE(out, static_cast<std::uint32_t>(manifestJson->size()));
|
||||
out.insert(out.end(), manifestJson->begin(), manifestJson->end());
|
||||
|
||||
if (!appendSpans(m.entries, out.size(), enc.layout, enc.totalSize))
|
||||
return std::nullopt;
|
||||
return enc;
|
||||
}
|
||||
|
||||
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
|
||||
std::uint64_t totalFileSize) {
|
||||
DecodedPackage dec; // status starts Malformed; every early return means it
|
||||
|
||||
wire::ByteReader r(prefix);
|
||||
if (r.str(kMagicBytes) != std::string(kPackageMagic, kMagicBytes)) return dec;
|
||||
|
||||
const std::uint32_t formatVersion = r.u32();
|
||||
const std::uint32_t minReader = r.u32();
|
||||
if (!r.ok) return dec;
|
||||
const PackageReadability verdict = classifyPackageVersion(formatVersion, minReader);
|
||||
if (verdict == PackageReadability::Malformed) return dec;
|
||||
|
||||
const std::uint32_t semverLen = r.u32();
|
||||
if (!r.ok || semverLen > kMaxWriterVersionBytes) return dec;
|
||||
std::string writer = r.str(semverLen);
|
||||
if (!r.ok) return dec;
|
||||
|
||||
dec.header = PackageHeader{formatVersion, minReader, std::move(writer)};
|
||||
if (verdict == PackageReadability::TooNew) {
|
||||
// Refuse whole: the header names the writer for the message; nothing
|
||||
// past the frozen region is read, and no manifest is produced.
|
||||
dec.status = PackageReadability::TooNew;
|
||||
return dec;
|
||||
}
|
||||
|
||||
const std::uint32_t manifestLen = r.u32();
|
||||
if (!r.ok || manifestLen > kMaxManifestBytes) return dec;
|
||||
const std::string manifestJson = r.str(manifestLen);
|
||||
if (!r.ok) return dec;
|
||||
|
||||
auto manifest = deserializeManifest(manifestJson);
|
||||
if (!manifest) return dec;
|
||||
|
||||
std::uint64_t end = 0;
|
||||
std::vector<PackageEntrySpan> layout;
|
||||
if (!appendSpans(manifest->entries, r.pos, layout, end)) return dec;
|
||||
// Exact-size proof: a byte missing (truncation) or a byte extra (trailing
|
||||
// garbage) both fail, even though no payload is read here.
|
||||
if (end != totalFileSize) return dec;
|
||||
|
||||
dec.status = PackageReadability::Readable;
|
||||
dec.manifest = std::move(*manifest);
|
||||
dec.layout = std::move(layout);
|
||||
dec.prefixSize = r.pos;
|
||||
return dec;
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < kSemverAt) return kSemverAt;
|
||||
if (!magicMatches(bytes)) return std::nullopt;
|
||||
|
||||
const auto verdict = classifyPackageVersion(u32At(bytes, 4), u32At(bytes, 8));
|
||||
if (verdict == PackageReadability::Malformed) return std::nullopt;
|
||||
|
||||
const std::uint32_t semverLen = u32At(bytes, kSemverLenAt);
|
||||
if (semverLen > kMaxWriterVersionBytes) return std::nullopt;
|
||||
const std::uint64_t throughSemver = kSemverAt + semverLen;
|
||||
if (verdict == PackageReadability::TooNew) return throughSemver;
|
||||
|
||||
if (bytes.size() < throughSemver + 4) return throughSemver + 4;
|
||||
const std::uint32_t manifestLen = u32At(bytes, static_cast<std::size_t>(throughSemver));
|
||||
if (manifestLen > kMaxManifestBytes) return std::nullopt;
|
||||
return throughSemver + 4 + manifestLen;
|
||||
}
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
// bank_package — RSBK framing and layout arithmetic: header encode, prefix
|
||||
// decode, and the ordered {name, offset, length} entry layout. Framing only,
|
||||
// never a payload: this module never holds, copies, or hashes an entry's audio
|
||||
// — the shell streams payloads one at a time against the layout produced here.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/package/package_format.h"
|
||||
#include "core/package/package_manifest.h"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
// Where one payload sits in the finished package file (absolute byte offset).
|
||||
struct PackageEntrySpan {
|
||||
std::string name;
|
||||
std::uint64_t offset = 0;
|
||||
std::uint64_t length = 0;
|
||||
|
||||
bool operator==(const PackageEntrySpan& o) const {
|
||||
return name == o.name && offset == o.offset && length == o.length;
|
||||
}
|
||||
};
|
||||
|
||||
// The write side's product: the prefix bytes (magic through manifest, written
|
||||
// verbatim as the file's head), the layout to stream each payload at, and the
|
||||
// finished file's exact size — what the shell verifies after the last append.
|
||||
struct EncodedPackage {
|
||||
std::vector<std::uint8_t> prefix;
|
||||
std::vector<PackageEntrySpan> layout;
|
||||
std::uint64_t totalSize = 0;
|
||||
};
|
||||
|
||||
// Encodes the package prefix for `m`, stamping this build's version pair and
|
||||
// version::stampVersion() as the writer semver. nullopt when the manifest
|
||||
// cannot be represented (serializeManifest's rejections) — refused on encode so
|
||||
// an undecodable package is never written.
|
||||
std::optional<EncodedPackage> encodePackage(const PackageManifest& m);
|
||||
|
||||
// The read side's product. header is meaningful for Readable and TooNew (a
|
||||
// refusal must still name the writer); manifest, layout, and prefixSize only
|
||||
// for Readable — TooNew produces NO manifest, so a refused decode cannot
|
||||
// half-succeed.
|
||||
struct DecodedPackage {
|
||||
PackageReadability status = PackageReadability::Malformed;
|
||||
PackageHeader header;
|
||||
PackageManifest manifest;
|
||||
std::vector<PackageEntrySpan> layout;
|
||||
std::uint64_t prefixSize = 0;
|
||||
};
|
||||
|
||||
// Decodes a package's leading bytes. `totalFileSize` is the on-disk size the
|
||||
// caller observed: decode proves prefix + payload lengths equal it exactly, so
|
||||
// a truncated or garbage-extended file is Malformed even though the payloads
|
||||
// themselves are never read here. `prefix` may be the whole file or any head of
|
||||
// it that requiredPrefixSize accepted. Error signaled, never UB.
|
||||
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
|
||||
std::uint64_t totalFileSize);
|
||||
|
||||
// How many leading bytes decodePackage needs. May grow as bytes arrive: with
|
||||
// fewer than the returned count on hand, read to that count and ask again.
|
||||
// For a TooNew package it stops at the frozen region (through the writer
|
||||
// semver) — field positions beyond it belong to the newer format and are not
|
||||
// trusted. nullopt: these bytes can never frame a package (bad magic,
|
||||
// incoherent versions, an over-cap length field) — stop reading.
|
||||
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "core/package/package_format.h"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
|
||||
std::uint32_t minReaderVersion) {
|
||||
if (formatVersion == 0 || minReaderVersion == 0) return PackageReadability::Malformed;
|
||||
if (minReaderVersion > formatVersion) return PackageReadability::Malformed;
|
||||
if (minReaderVersion > kPackageFormatVersion) return PackageReadability::TooNew;
|
||||
return PackageReadability::Readable;
|
||||
}
|
||||
|
||||
bool isValidEntryName(const std::string& name) {
|
||||
if (name.empty() || name.size() > kMaxEntryNameBytes) return false;
|
||||
if (name == ".") return false;
|
||||
if (name.find("..") != std::string::npos) return false;
|
||||
for (char c : name) {
|
||||
if (c == '/' || c == '\\' || c == ':') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
// package_format — the RSBK bank-package contract: magic, the version ladder,
|
||||
// the readability classification, and the entry-name rule. Pure: standard
|
||||
// library only. The framing codec that acts on this contract is bank_package;
|
||||
// the manifest grammar is package_manifest.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
// Version ladder for the RSBK container (read-and-validate, like the origin
|
||||
// ledger's "v"):
|
||||
//
|
||||
// format 1 (current) magic "RSBK" | u32 formatVersion | u32 minReaderVersion
|
||||
// | u32 len + writer semver | u32 len + JSON manifest
|
||||
// | payloads concatenated in manifest entry order.
|
||||
// All integers little-endian.
|
||||
//
|
||||
// Two integers, two jobs: formatVersion is what the writer emitted (monotonic,
|
||||
// bumped on ANY change); minReaderVersion is the oldest reader that can read the
|
||||
// package safely (bumped only on a STRUCTURAL change — a field's meaning shifts,
|
||||
// a section is removed, framing moves; an additive change — a new optional
|
||||
// manifest key, a new enum value with a defined degrade — leaves it alone). The
|
||||
// reader's whole rule: read iff minReaderVersion <= kPackageFormatVersion.
|
||||
// formatVersion beyond that is message text and log material only.
|
||||
//
|
||||
// FROZEN FOR ALL FUTURE VERSIONS: the fields through the writer semver. A
|
||||
// too-new package must still yield the writer's version so the refusal can name
|
||||
// what to install — a structural change may rearrange anything after the semver,
|
||||
// never before it.
|
||||
inline constexpr char kPackageMagic[4] = {'R', 'S', 'B', 'K'};
|
||||
inline constexpr std::uint32_t kPackageFormatVersion = 1;
|
||||
inline constexpr std::uint32_t kPackageMinReaderVersion = 1;
|
||||
|
||||
// Hostile-input allocation caps (error signaled, never a multi-gigabyte
|
||||
// allocation off a forged length field). Generous against real content: a
|
||||
// semver is ~10 bytes; a manifest for hundreds of samples is well under 1 MB.
|
||||
inline constexpr std::uint32_t kMaxWriterVersionBytes = 64;
|
||||
inline constexpr std::uint32_t kMaxManifestBytes = 64u * 1024u * 1024u;
|
||||
inline constexpr std::size_t kMaxEntryNameBytes = 255;
|
||||
|
||||
// The three-way read verdict (the FutureVersion precedent): TooNew refuses the
|
||||
// whole package before anything is produced; Malformed is a header no honest
|
||||
// writer emits. Also the status of a full prefix decode in bank_package.
|
||||
enum class PackageReadability {
|
||||
Readable,
|
||||
TooNew,
|
||||
Malformed,
|
||||
};
|
||||
|
||||
// Classify a stored header pair against THIS build's ladder. minReaderVersion
|
||||
// above kPackageFormatVersion is TooNew; a zero version or minReader >
|
||||
// formatVersion is Malformed (a writer cannot require a reader newer than what
|
||||
// it wrote).
|
||||
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
|
||||
std::uint32_t minReaderVersion);
|
||||
|
||||
// The entry-name rule that makes path expression structurally impossible: a
|
||||
// bare file name only. Rejects empty, ".", any ".." occurrence, any '/', '\\'
|
||||
// or ':' (which also bans every absolute form — drive, UNC, rooted), and names
|
||||
// over kMaxEntryNameBytes. Enforced on encode AND decode by package_manifest.
|
||||
bool isValidEntryName(const std::string& name);
|
||||
|
||||
// The fixed header, informational semver included. writerVersion is
|
||||
// version::stampVersion() on the write side — it exists so a TooNew refusal can
|
||||
// tell the user which build to install; it never gates.
|
||||
struct PackageHeader {
|
||||
std::uint32_t formatVersion = kPackageFormatVersion;
|
||||
std::uint32_t minReaderVersion = kPackageMinReaderVersion;
|
||||
std::string writerVersion;
|
||||
|
||||
bool operator==(const PackageHeader& o) const {
|
||||
return formatVersion == o.formatVersion &&
|
||||
minReaderVersion == o.minReaderVersion &&
|
||||
writerVersion == o.writerVersion;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,201 @@
|
||||
#include "core/package/package_manifest.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "core/json/json.h"
|
||||
#include "core/package/package_format.h"
|
||||
|
||||
// Duplicate entry names are rejected in both directions: two payloads landing
|
||||
// on one destination name is incoherent, and on import it would be a silent
|
||||
// overwrite. Sample-id rules (remap, collision) are deliberately NOT enforced
|
||||
// here — they are the import plan's decisions, not the codec's.
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
namespace {
|
||||
|
||||
using json::numToStr;
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
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 (entries[i].fileName == entries[j].fileName) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The one-sample BankModel image of `s` — bank_model's own writer, verbatim, so
|
||||
// the per-sample shape has exactly one owner. nullopt when add() would reject
|
||||
// the record (its guards are the format's guards too).
|
||||
std::optional<std::string> nestSample(const model::Sample& s) {
|
||||
model::BankModel one;
|
||||
if (one.add(s) != model::AddResult::Added) return std::nullopt;
|
||||
return one.serialize();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PackageEntry::operator==(const PackageEntry& o) const {
|
||||
return fileName == o.fileName && byteLength == o.byteLength &&
|
||||
byteHash == o.byteHash && sample == o.sample;
|
||||
}
|
||||
|
||||
bool PackageManifest::operator==(const PackageManifest& o) const {
|
||||
return bankDisplayName == o.bankDisplayName && exportTimestamp == o.exportTimestamp &&
|
||||
entries == o.entries && slots == o.slots;
|
||||
}
|
||||
|
||||
std::optional<std::string> serializeManifest(const PackageManifest& m) {
|
||||
for (const auto& e : m.entries)
|
||||
if (!isValidEntryName(e.fileName)) return std::nullopt;
|
||||
if (duplicateName(m.entries)) return std::nullopt;
|
||||
|
||||
std::string out;
|
||||
{
|
||||
ObjWriter root(out);
|
||||
root.keyStr("bankName", m.bankDisplayName);
|
||||
root.keyRaw("exported", numToStr(m.exportTimestamp));
|
||||
|
||||
root.keyBegin("entries");
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < m.entries.size(); ++i) {
|
||||
const auto& e = m.entries[i];
|
||||
auto nested = nestSample(e.sample);
|
||||
if (!nested) return std::nullopt;
|
||||
if (i) out += ',';
|
||||
ObjWriter w(out);
|
||||
w.keyStr("name", e.fileName);
|
||||
// byteLength rides as a signed decimal; 2^63 bytes is beyond any file.
|
||||
w.keyRaw("length", numToStr(static_cast<std::int64_t>(e.byteLength)));
|
||||
w.keyStr("hash", e.byteHash);
|
||||
w.keyRaw("index", *nested);
|
||||
}
|
||||
out += ']';
|
||||
|
||||
root.keyBegin("slots");
|
||||
out += m.slots.serialize();
|
||||
} // root closes here (NRVO note in json::Writer)
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Mirrors bank_book_json's private slots parser: [{id, slot}, ...] pairs handed
|
||||
// to SlotMap::fromEntries, which owns the defensive repair rules.
|
||||
bool parseSlots(json::Reader& r, model::SlotMap& out) {
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) {
|
||||
out = model::SlotMap::fromEntries(pairs);
|
||||
return true;
|
||||
}
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool haveId = false, haveSlot = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!r.skipValue()) return false; }
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || !haveSlot) return false;
|
||||
pairs.emplace_back(std::move(id), slot);
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
out = model::SlotMap::fromEntries(pairs);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseEntry(json::Reader& r, PackageEntry& e) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // an entry needs all four fields
|
||||
|
||||
bool haveName = false, haveLength = false, haveHash = false, haveSample = false;
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "name") {
|
||||
if (!r.parseString(e.fileName)) return false;
|
||||
haveName = true;
|
||||
} else if (key == "length") {
|
||||
std::int64_t v = 0;
|
||||
if (!r.parseInt64(v)) return false;
|
||||
if (v < 0) return false;
|
||||
e.byteLength = static_cast<std::uint64_t>(v);
|
||||
haveLength = true;
|
||||
} else if (key == "hash") {
|
||||
if (!r.parseString(e.byteHash)) return false;
|
||||
haveHash = true;
|
||||
} else if (key == "index") {
|
||||
std::string raw;
|
||||
if (!r.captureValue(raw)) return false;
|
||||
auto idx = model::BankModel::deserialize(raw);
|
||||
// Exactly one sample: add()'s silent drop (rejected record) or a
|
||||
// multi-sample blob both fail the entry rather than half-parse.
|
||||
if (!idx || idx->size() != 1) return false;
|
||||
e.sample = idx->all().front();
|
||||
haveSample = true;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
|
||||
return isValidEntryName(e.fileName);
|
||||
}
|
||||
|
||||
bool parseManifest(json::Reader& r, PackageManifest& m) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object: a valid empty manifest
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "bankName") {
|
||||
if (!r.parseString(m.bankDisplayName)) return false;
|
||||
} else if (key == "exported") {
|
||||
if (!r.parseInt64(m.exportTimestamp)) return false;
|
||||
} else if (key == "entries") {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
PackageEntry e;
|
||||
if (!parseEntry(r, e)) return false;
|
||||
m.entries.push_back(std::move(e));
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "slots") {
|
||||
if (!parseSlots(r, m.slots)) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false; // trailing garbage
|
||||
return !duplicateName(m.entries);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<PackageManifest> deserializeManifest(const std::string& json) {
|
||||
PackageManifest m;
|
||||
json::Reader r(json);
|
||||
if (!parseManifest(r, m)) return std::nullopt;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace reasampler::package
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
// package_manifest — the RSBK manifest model and its JSON codec. Per-sample
|
||||
// shape is NOT owned here: each entry nests a one-sample BankModel blob emitted
|
||||
// by bank_model's own writer (the bank_book_json precedent), so a future Sample
|
||||
// field reaches packages for free. Pure: no filesystem, no host types.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/slot_map.h"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
// One payload's transport record. `byteHash` is capture::hashBytes over the
|
||||
// payload's raw bytes — the whole-file digest, deliberately NOT hashWavContent
|
||||
// (which skips chunks and so cannot answer "did these bytes survive the trip").
|
||||
// FNV-1a: a corruption detector, not a cryptographic checksum. The codec only
|
||||
// carries the digest; hashing happens where the payload is streamed (shell).
|
||||
struct PackageEntry {
|
||||
std::string fileName; // bare name inside the package (isValidEntryName)
|
||||
std::uint64_t byteLength = 0;
|
||||
std::string byteHash;
|
||||
model::Sample sample;
|
||||
|
||||
bool operator==(const PackageEntry& o) const;
|
||||
};
|
||||
|
||||
// Everything the manifest carries besides the payloads: informational envelope
|
||||
// (source bank name, export moment), the entries, and the bank's display
|
||||
// positions (a bank's arrangement is part of what the user built).
|
||||
struct PackageManifest {
|
||||
std::string bankDisplayName;
|
||||
std::int64_t exportTimestamp = 0; // unix epoch seconds
|
||||
std::vector<PackageEntry> entries;
|
||||
model::SlotMap slots;
|
||||
|
||||
bool operator==(const PackageManifest& o) const;
|
||||
};
|
||||
|
||||
// Emits the manifest JSON. nullopt when the manifest cannot be represented:
|
||||
// an invalid or duplicate entry name, or a sample record BankModel itself would
|
||||
// reject (empty id, absolute path) — refusing on encode so an undecodable
|
||||
// package is never written.
|
||||
std::optional<std::string> serializeManifest(const PackageManifest& m);
|
||||
|
||||
// Parses manifest JSON (nullopt on malformed input, never UB). Unknown keys are
|
||||
// skipped at every level, so an additive newer manifest still parses. Rejects
|
||||
// what encode rejects — entry names are validated on BOTH directions because a
|
||||
// package can arrive from anywhere — plus a missing per-entry field or a
|
||||
// negative length.
|
||||
std::optional<PackageManifest> deserializeManifest(const std::string& json);
|
||||
|
||||
} // namespace reasampler::package
|
||||
Reference in New Issue
Block a user