1198 lines
48 KiB
Rust
1198 lines
48 KiB
Rust
//! The inverse of [`OperationEnvelope::encode_canonical`] — bytes back to an
|
|
//! envelope (Binary Format companion §"Operation Envelope Encoding").
|
|
//!
|
|
//! Until this existed the format was **write-only for operations**: a bundle's
|
|
//! envelope blocks decoded to opaque byte strings ([`crate::fuzz`]'s corpus
|
|
//! notwithstanding), `OperationKind` had an encoder and no decoder, and nothing
|
|
//! in the workspace could turn a saved document's operations back into an
|
|
//! [`OperationSet`](crate::OperationSet). Chapter 6 holds that a score's
|
|
//! canonical state *is* the set of operations committed to it, so a bundle whose
|
|
//! operations cannot be read is a bundle whose score cannot be reopened.
|
|
//!
|
|
//! ## Strictness
|
|
//!
|
|
//! Decode is **strict-canonical**: an accepted byte string re-encodes to itself.
|
|
//! That is enforced complete-by-construction at the envelope boundary — the
|
|
//! decoded envelope is re-encoded and compared — which is sound *here* because
|
|
//! every sequence in the envelope encoding is normalized by its encoder
|
|
//! (`sorted_canonical`, `BTreeSet`, `BTreeMap`). Where an encoder writes a `Vec`
|
|
//! verbatim a guard is blind and a per-site order check is required; see
|
|
//! `DECISIONS.md` §"Push 5 / P2".
|
|
//!
|
|
//! Two rules are checked per-site anyway, because they deserve their own error
|
|
//! rather than a bare `NonCanonical`, and because a future encoder change must
|
|
//! not silently relax them:
|
|
//!
|
|
//! * `TransposeInterval.targets` is a **set**: its sequence must be strictly
|
|
//! increasing. A duplicate is rejected, never normalized away
|
|
//! (Binary Format `seq^⇑`; `req:opcat:transpose-interval-targets`).
|
|
//! * `Transpose.targets` is the frozen **multiset**: non-decreasing, duplicates
|
|
//! preserved (`req:opcat:transpose-frozen`).
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use epiphany_core::{
|
|
AnalysisLayer, CanvasLayoutDefaults, EventId, Instrument, InstrumentId, Measure, MetricGrid,
|
|
MusicalPosition, OperationId, PartDefinition, PitchId, PitchSpelling, RegionId,
|
|
RegionTimeModel, RepeatStructureId, ReplicaId, ScoreMetadata, SpellingPrecedence, Staff,
|
|
StaffGroup, StaffInstance, StaffInstanceId, StaffLineConfiguration, TimeAnchor, TimeSignature,
|
|
TranspositionInterval, TuningContextSettings, TupletId, TypedObjectId, ViewDefinition, Voice,
|
|
VoiceId, WallClockTime,
|
|
};
|
|
use epiphany_core::{
|
|
Beam, Event, IdentifiedPitch, Pitch, Region, RepeatStructure, Rest, Slur, Spanner, Tie,
|
|
};
|
|
use epiphany_core::{CanonicalValue, TempoSegment};
|
|
use epiphany_determinism::{CanonicalDecode, CanonicalEncode};
|
|
|
|
use crate::causal::CausalContext;
|
|
use crate::conflict::{ConflictId, ResolutionAction};
|
|
use crate::envelope::{EnvelopeHash, OperationEnvelope};
|
|
use crate::payload::*;
|
|
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
|
use crate::support::ResolutionRegistryId;
|
|
use crate::support::{AuthorId, OperationKindRegistryId};
|
|
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
|
use epiphany_core::TransactionId;
|
|
|
|
/// Why an operation envelope failed to decode.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum EnvelopeDecodeError {
|
|
/// A field ended before its declared or fixed width.
|
|
UnexpectedEof,
|
|
/// A `u32` length or count does not fit this process's `usize`.
|
|
LengthOverflow,
|
|
/// A declared count or length exceeds the bytes remaining. Rejected before
|
|
/// any allocation: a garbage `u32` must not become a 4-billion-element
|
|
/// reservation, nor a walk toward EOF.
|
|
CountExceedsRemaining { declared: usize, remaining: usize },
|
|
/// A tagged union carried an unknown discriminant.
|
|
InvalidTag { kind: &'static str, tag: u8 },
|
|
/// An embedded canonical value failed its own decoder.
|
|
InvalidValue(&'static str),
|
|
/// A canonical boolean was not `0` or `1`.
|
|
InvalidBoolean(u8),
|
|
/// A canonical text field was not UTF-8.
|
|
InvalidUtf8,
|
|
/// A canonical text field was not in Unicode NFC.
|
|
NotNfc,
|
|
/// Bytes remained after the envelope was decoded.
|
|
TrailingBytes,
|
|
/// `TransposeInterval.targets` is a set: strictly increasing, no duplicates.
|
|
TargetsNotStrictlyIncreasing,
|
|
/// `Transpose.targets` is a frozen multiset: non-decreasing.
|
|
TargetsNotSorted,
|
|
/// The bytes decoded structurally but are not the canonical encoding of the
|
|
/// value they decode to.
|
|
NonCanonical,
|
|
}
|
|
|
|
impl core::fmt::Display for EnvelopeDecodeError {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
match self {
|
|
Self::UnexpectedEof => f.write_str("unexpected end of envelope bytes"),
|
|
Self::LengthOverflow => f.write_str("envelope length does not fit usize"),
|
|
Self::CountExceedsRemaining {
|
|
declared,
|
|
remaining,
|
|
} => write!(
|
|
f,
|
|
"declared count {declared} exceeds {remaining} bytes remaining"
|
|
),
|
|
Self::InvalidTag { kind, tag } => write!(f, "invalid {kind} tag {tag}"),
|
|
Self::InvalidValue(kind) => write!(f, "invalid canonical {kind}"),
|
|
Self::InvalidBoolean(v) => write!(f, "invalid canonical boolean {v}"),
|
|
Self::InvalidUtf8 => f.write_str("invalid UTF-8 in canonical text"),
|
|
Self::NotNfc => f.write_str("canonical text is not NFC"),
|
|
Self::TrailingBytes => f.write_str("trailing bytes after envelope"),
|
|
Self::TargetsNotStrictlyIncreasing => f.write_str(
|
|
"TransposeInterval targets are a set: strictly increasing, no duplicates",
|
|
),
|
|
Self::TargetsNotSorted => f.write_str("Transpose targets are not sorted"),
|
|
Self::NonCanonical => f.write_str("envelope bytes are not canonical"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for EnvelopeDecodeError {}
|
|
|
|
type Result<T> = core::result::Result<T, EnvelopeDecodeError>;
|
|
|
|
struct Reader<'a> {
|
|
bytes: &'a [u8],
|
|
pos: usize,
|
|
}
|
|
|
|
impl<'a> Reader<'a> {
|
|
fn new(bytes: &'a [u8]) -> Self {
|
|
Reader { bytes, pos: 0 }
|
|
}
|
|
|
|
fn remaining(&self) -> usize {
|
|
self.bytes.len() - self.pos
|
|
}
|
|
|
|
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
|
if self.remaining() < n {
|
|
return Err(EnvelopeDecodeError::UnexpectedEof);
|
|
}
|
|
let out = &self.bytes[self.pos..self.pos + n];
|
|
self.pos += n;
|
|
Ok(out)
|
|
}
|
|
|
|
fn byte(&mut self) -> Result<u8> {
|
|
Ok(self.take(1)?[0])
|
|
}
|
|
|
|
fn u32_le(&mut self) -> Result<u32> {
|
|
Ok(u32::from_le_bytes(self.take(4)?.try_into().expect("4")))
|
|
}
|
|
|
|
fn u64_le(&mut self) -> Result<u64> {
|
|
Ok(u64::from_le_bytes(self.take(8)?.try_into().expect("8")))
|
|
}
|
|
|
|
/// A `u32` count or length, bounded by the bytes remaining. Every element of
|
|
/// every sequence in this encoding costs at least one byte, so a count past
|
|
/// the remainder is garbage — rejected before it can drive an allocation.
|
|
fn count(&mut self) -> Result<usize> {
|
|
let declared =
|
|
usize::try_from(self.u32_le()?).map_err(|_| EnvelopeDecodeError::LengthOverflow)?;
|
|
if declared > self.remaining() {
|
|
return Err(EnvelopeDecodeError::CountExceedsRemaining {
|
|
declared,
|
|
remaining: self.remaining(),
|
|
});
|
|
}
|
|
Ok(declared)
|
|
}
|
|
|
|
fn lp_bytes(&mut self) -> Result<&'a [u8]> {
|
|
let n = self.count()?;
|
|
self.take(n)
|
|
}
|
|
|
|
fn bool(&mut self) -> Result<bool> {
|
|
match self.byte()? {
|
|
0 => Ok(false),
|
|
1 => Ok(true),
|
|
other => Err(EnvelopeDecodeError::InvalidBoolean(other)),
|
|
}
|
|
}
|
|
|
|
fn string(&mut self) -> Result<String> {
|
|
let raw = self.lp_bytes()?;
|
|
let s = core::str::from_utf8(raw).map_err(|_| EnvelopeDecodeError::InvalidUtf8)?;
|
|
// Appendix D: canonical text is NFC. A non-NFC string would be
|
|
// normalized on re-encode, so the boundary guard would reject it — but
|
|
// name the failure here rather than call it "non-canonical".
|
|
use unicode_normalization::UnicodeNormalization;
|
|
if s.nfc().collect::<String>() != s {
|
|
return Err(EnvelopeDecodeError::NotNfc);
|
|
}
|
|
Ok(s.to_string())
|
|
}
|
|
|
|
/// A `count`-prefixed sequence of length-prefixed elements.
|
|
fn seq<T>(&mut self, mut each: impl FnMut(&[u8]) -> Result<T>) -> Result<Vec<T>> {
|
|
let n = self.count()?;
|
|
let mut out = Vec::with_capacity(n.min(1024));
|
|
for _ in 0..n {
|
|
let elem = self.lp_bytes()?;
|
|
out.push(each(elem)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn finish(self) -> Result<()> {
|
|
if self.pos == self.bytes.len() {
|
|
Ok(())
|
|
} else {
|
|
Err(EnvelopeDecodeError::TrailingBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A fixed-width canonical primitive read directly from the reader.
|
|
fn canon<T: CanonicalDecode>(r: &mut Reader<'_>, width: usize, kind: &'static str) -> Result<T> {
|
|
let bytes = r.take(width)?;
|
|
T::decode_canonical(bytes).map_err(|_| EnvelopeDecodeError::InvalidValue(kind))
|
|
}
|
|
|
|
/// An embedded [`CanonicalValue`] behind its `u32` length prefix.
|
|
fn value<T: CanonicalValue>(r: &mut Reader<'_>, kind: &'static str) -> Result<T> {
|
|
let bytes = r.lp_bytes()?;
|
|
T::decode_canonical(bytes).map_err(|_| EnvelopeDecodeError::InvalidValue(kind))
|
|
}
|
|
|
|
/// A whole `T` from exactly `bytes` (a sequence element).
|
|
fn exact<T: CanonicalDecode>(bytes: &[u8], kind: &'static str) -> Result<T> {
|
|
T::decode_canonical(bytes).map_err(|_| EnvelopeDecodeError::InvalidValue(kind))
|
|
}
|
|
|
|
fn opt<T>(
|
|
r: &mut Reader<'_>,
|
|
mut some: impl FnMut(&mut Reader<'_>) -> Result<T>,
|
|
) -> Result<Option<T>> {
|
|
match r.byte()? {
|
|
0 => Ok(None),
|
|
1 => Ok(Some(some(r)?)),
|
|
tag => Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "Option",
|
|
tag,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// --- Identifiers (all 16 big-endian bytes) ---------------------------------
|
|
|
|
macro_rules! id_reader {
|
|
($fn_name:ident, $ty:ty, $kind:literal) => {
|
|
fn $fn_name(r: &mut Reader<'_>) -> Result<$ty> {
|
|
canon::<$ty>(r, 16, $kind)
|
|
}
|
|
};
|
|
}
|
|
id_reader!(operation_id, OperationId, "OperationId");
|
|
id_reader!(event_id, EventId, "EventId");
|
|
id_reader!(pitch_id, PitchId, "PitchId");
|
|
id_reader!(region_id, RegionId, "RegionId");
|
|
id_reader!(staff_instance_id, StaffInstanceId, "StaffInstanceId");
|
|
id_reader!(voice_id, VoiceId, "VoiceId");
|
|
id_reader!(instrument_id, InstrumentId, "InstrumentId");
|
|
id_reader!(repeat_id, RepeatStructureId, "RepeatStructureId");
|
|
id_reader!(transaction_id, TransactionId, "TransactionId");
|
|
|
|
/// `TypedObjectId` is variable-width: a 2-byte big-endian discriminant, then a
|
|
/// 16-byte payload — or, for `Registered` (discriminant 27), a 16-byte registry
|
|
/// id followed by 16 more extension bytes. The discriminant must be read before
|
|
/// the width is known.
|
|
const TYPED_OBJECT_ID_REGISTERED: u16 = 27;
|
|
fn typed_object_id(r: &mut Reader<'_>) -> Result<TypedObjectId> {
|
|
if r.remaining() < 2 {
|
|
return Err(EnvelopeDecodeError::UnexpectedEof);
|
|
}
|
|
let discriminant = u16::from_be_bytes(r.bytes[r.pos..r.pos + 2].try_into().expect("2"));
|
|
let width = if discriminant == TYPED_OBJECT_ID_REGISTERED {
|
|
2 + 16 + 16
|
|
} else {
|
|
2 + 16
|
|
};
|
|
canon::<TypedObjectId>(r, width, "TypedObjectId")
|
|
}
|
|
|
|
fn u128_id(r: &mut Reader<'_>) -> Result<u128> {
|
|
Ok(u128::from_be_bytes(r.take(16)?.try_into().expect("16")))
|
|
}
|
|
|
|
// --- Envelope scaffolding ---------------------------------------------------
|
|
|
|
fn hlc(r: &mut Reader<'_>) -> Result<HybridLogicalClock> {
|
|
let physical = WallClockTime(i64::from_le_bytes(r.take(8)?.try_into().expect("8")));
|
|
let logical_counter = r.u32_le()?;
|
|
Ok(HybridLogicalClock::new(physical, logical_counter))
|
|
}
|
|
|
|
fn stamp(r: &mut Reader<'_>) -> Result<OperationStamp> {
|
|
let clock = hlc(r)?;
|
|
let id = operation_id(r)?;
|
|
Ok(OperationStamp::new(clock, id))
|
|
}
|
|
|
|
fn causal_context(r: &mut Reader<'_>) -> Result<CausalContext> {
|
|
let mut ctx = CausalContext::new();
|
|
let vector_len = r.count()?;
|
|
for _ in 0..vector_len {
|
|
let replica = ReplicaId(u64::from_be_bytes(r.take(8)?.try_into().expect("8")));
|
|
let counter = r.u64_le()?;
|
|
ctx = ctx.with_seen(replica, counter);
|
|
}
|
|
let dots = r.count()?;
|
|
for _ in 0..dots {
|
|
ctx = ctx.with_dot(operation_id(r)?);
|
|
}
|
|
Ok(ctx)
|
|
}
|
|
|
|
// --- Sub-values -------------------------------------------------------------
|
|
|
|
fn tuplet_compensation(r: &mut Reader<'_>) -> Result<TupletCompensation> {
|
|
Ok(match r.byte()? {
|
|
0 => TupletCompensation::NotInTuplet,
|
|
1 => TupletCompensation::ReplaceWithRest {
|
|
rest: value::<Rest>(r, "Rest")?,
|
|
},
|
|
2 => TupletCompensation::RewriteTuplets {
|
|
tuplets: r.seq(|b| exact::<TupletId>(b, "TupletId"))?,
|
|
},
|
|
3 => TupletCompensation::CascadeDeleteTuplets {
|
|
tuplets: r.seq(|b| exact::<TupletId>(b, "TupletId"))?,
|
|
},
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "TupletCompensation",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn cross_cutting(r: &mut Reader<'_>) -> Result<CrossCuttingValue> {
|
|
Ok(match r.byte()? {
|
|
0 => CrossCuttingValue::Tie(value::<Tie>(r, "Tie")?),
|
|
1 => CrossCuttingValue::Slur(value::<Slur>(r, "Slur")?),
|
|
2 => CrossCuttingValue::Beam(value::<Beam>(r, "Beam")?),
|
|
3 => CrossCuttingValue::Spanner(value::<Spanner>(r, "Spanner")?),
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "CrossCuttingValue",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn position_remapping(r: &mut Reader<'_>) -> Result<PositionRemapping> {
|
|
Ok(match r.byte()? {
|
|
0 => PositionRemapping::PreserveTime,
|
|
1 => {
|
|
let n = r.count()?;
|
|
let mut entries = Vec::with_capacity(n.min(1024));
|
|
for _ in 0..n {
|
|
let event = event_id(r)?;
|
|
let bytes = r.lp_bytes()?;
|
|
entries.push((event, exact::<MusicalPosition>(bytes, "MusicalPosition")?));
|
|
}
|
|
PositionRemapping::Reassign(entries)
|
|
}
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "PositionRemapping",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn transaction_category(r: &mut Reader<'_>) -> Result<TransactionCategory> {
|
|
Ok(match r.byte()? {
|
|
0 => TransactionCategory::NoteEntry,
|
|
1 => TransactionCategory::Structural,
|
|
2 => TransactionCategory::Layout,
|
|
3 => TransactionCategory::Import,
|
|
4 => TransactionCategory::Registered(OperationKindRegistryId(u128_id(r)?)),
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "TransactionCategory",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn transaction_descriptor(r: &mut Reader<'_>) -> Result<TransactionDescriptor> {
|
|
let id = transaction_id(r)?;
|
|
let label = r.string()?;
|
|
let category = opt(r, transaction_category)?;
|
|
Ok(TransactionDescriptor {
|
|
id,
|
|
label,
|
|
category,
|
|
})
|
|
}
|
|
|
|
fn resolution_action(r: &mut Reader<'_>) -> Result<ResolutionAction> {
|
|
Ok(match r.byte()? {
|
|
0 => ResolutionAction::AcceptLoser,
|
|
1 => ResolutionAction::KeepWinner,
|
|
2 => ResolutionAction::Override {
|
|
override_operation: operation_id(r)?,
|
|
},
|
|
3 => ResolutionAction::Reanchor {
|
|
new_target: typed_object_id(r)?,
|
|
},
|
|
4 => ResolutionAction::Dismiss,
|
|
5 => ResolutionAction::Registered(ResolutionRegistryId(u128_id(r)?)),
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "ResolutionAction",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn undo_policy(r: &mut Reader<'_>) -> Result<UndoPolicy> {
|
|
Ok(match r.byte()? {
|
|
0 => UndoPolicy::StrictInverse,
|
|
1 => UndoPolicy::BestEffort,
|
|
2 => UndoPolicy::Cascade,
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "UndoPolicy",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Operation kinds --------------------------------------------------------
|
|
|
|
fn operation_kind(r: &mut Reader<'_>) -> Result<OperationKind> {
|
|
let tag = r.byte()?;
|
|
Ok(match tag {
|
|
0 => OperationKind::InsertEvent(InsertEventOp {
|
|
staff_instance: staff_instance_id(r)?,
|
|
event: value::<Event>(r, "Event")?,
|
|
}),
|
|
1 => OperationKind::DeleteEvent(DeleteEventOp {
|
|
event: event_id(r)?,
|
|
tuplet_compensation: tuplet_compensation(r)?,
|
|
}),
|
|
2 => OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: pitch_id(r)?,
|
|
spelling: value::<PitchSpelling>(r, "PitchSpelling")?,
|
|
}),
|
|
3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
|
structure: cross_cutting(r)?,
|
|
}),
|
|
4 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
|
|
region: region_id(r)?,
|
|
new_time_model: value::<RegionTimeModel>(r, "RegionTimeModel")?,
|
|
declared_incompatible: r.seq(|b| exact::<EventId>(b, "EventId"))?,
|
|
remapping: position_remapping(r)?,
|
|
}),
|
|
5 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
|
region: region_id(r)?,
|
|
anchor: value::<TimeAnchor>(r, "TimeAnchor")?,
|
|
present: r.bool()?,
|
|
}),
|
|
6 => OperationKind::DeclareTransaction(transaction_descriptor(r)?),
|
|
7 => {
|
|
let id = OperationKindRegistryId(u128_id(r)?);
|
|
let bytes = r.lp_bytes()?.to_vec();
|
|
OperationKind::Registered(id, bytes)
|
|
}
|
|
8 => OperationKind::ModifyEvent(ModifyEventOp {
|
|
event: value::<Event>(r, "Event")?,
|
|
}),
|
|
9 => {
|
|
let targets = r.seq(|b| exact::<PitchId>(b, "PitchId"))?;
|
|
// The frozen multiset: non-decreasing, duplicates preserved.
|
|
if targets
|
|
.windows(2)
|
|
.any(|w| w[0].to_canonical_bytes() > w[1].to_canonical_bytes())
|
|
{
|
|
return Err(EnvelopeDecodeError::TargetsNotSorted);
|
|
}
|
|
let chromatic_steps = i32::from_le_bytes(r.take(4)?.try_into().expect("4"));
|
|
OperationKind::Transpose(TransposeOp {
|
|
targets,
|
|
chromatic_steps,
|
|
})
|
|
}
|
|
10 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
|
event: event_id(r)?,
|
|
pitch: value::<IdentifiedPitch>(r, "IdentifiedPitch")?,
|
|
}),
|
|
11 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
|
|
pitch: pitch_id(r)?,
|
|
}),
|
|
12 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
|
pitch: pitch_id(r)?,
|
|
value: value::<Pitch>(r, "Pitch")?,
|
|
}),
|
|
13 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
|
|
structure: typed_object_id(r)?,
|
|
}),
|
|
14 => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
|
|
structure: cross_cutting(r)?,
|
|
}),
|
|
15 => OperationKind::CreateRegion(CreateRegionOp {
|
|
region: value::<Region>(r, "Region")?,
|
|
}),
|
|
16 => OperationKind::DeleteRegion(DeleteRegionOp {
|
|
region: region_id(r)?,
|
|
}),
|
|
17 => OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
|
|
region: region_id(r)?,
|
|
instance: value::<StaffInstance>(r, "StaffInstance")?,
|
|
}),
|
|
18 => OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
|
|
staff_instance: staff_instance_id(r)?,
|
|
}),
|
|
19 => OperationKind::CreateVoice(CreateVoiceOp {
|
|
staff_instance: staff_instance_id(r)?,
|
|
voice: value::<Voice>(r, "Voice")?,
|
|
}),
|
|
20 => OperationKind::DeleteVoice(DeleteVoiceOp {
|
|
voice: voice_id(r)?,
|
|
}),
|
|
21 => OperationKind::SetMetadata(SetMetadataOp {
|
|
metadata: value::<ScoreMetadata>(r, "ScoreMetadata")?,
|
|
}),
|
|
22 => OperationKind::SetMetricGrid(SetMetricGridOp {
|
|
region: region_id(r)?,
|
|
grid: opt(r, |r| value::<MetricGrid>(r, "MetricGrid"))?,
|
|
}),
|
|
23 => OperationKind::SetUserPageBreak(SetUserPageBreakOp {
|
|
region: region_id(r)?,
|
|
anchor: value::<TimeAnchor>(r, "TimeAnchor")?,
|
|
present: r.bool()?,
|
|
}),
|
|
24 => OperationKind::CreateStaff(CreateStaffOp {
|
|
staff: value::<Staff>(r, "Staff")?,
|
|
}),
|
|
25 => OperationKind::SetTimeSignature(SetTimeSignatureOp {
|
|
region: region_id(r)?,
|
|
anchor: value::<TimeAnchor>(r, "TimeAnchor")?,
|
|
time_signature: opt(r, |r| value::<TimeSignature>(r, "TimeSignature"))?,
|
|
}),
|
|
26 => OperationKind::SetTempoSegment(SetTempoSegmentOp {
|
|
region: opt(r, region_id)?,
|
|
start: value::<TimeAnchor>(r, "TimeAnchor")?,
|
|
segment: opt(r, |r| value::<TempoSegment>(r, "TempoSegment"))?,
|
|
}),
|
|
27 => OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
|
staff_instance: staff_instance_id(r)?,
|
|
instrument_override: opt(r, instrument_id)?,
|
|
staff_lines_override: opt(r, |r| {
|
|
value::<StaffLineConfiguration>(r, "StaffLineConfiguration")
|
|
})?,
|
|
visible: r.bool()?,
|
|
}),
|
|
28 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
|
|
repeat: value::<RepeatStructure>(r, "RepeatStructure")?,
|
|
}),
|
|
29 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
|
|
repeat: repeat_id(r)?,
|
|
}),
|
|
30 => {
|
|
let targets = r.seq(|b| exact::<PitchId>(b, "PitchId"))?;
|
|
// `seq^⇑`: a set. Strictly increasing, and a duplicate is REJECTED,
|
|
// never normalized away by the `BTreeSet` this collects into.
|
|
if targets
|
|
.windows(2)
|
|
.any(|w| w[0].to_canonical_bytes() >= w[1].to_canonical_bytes())
|
|
{
|
|
return Err(EnvelopeDecodeError::TargetsNotStrictlyIncreasing);
|
|
}
|
|
let diatonic_steps = i32::from_le_bytes(r.take(4)?.try_into().expect("4"));
|
|
let chromatic_steps = i32::from_le_bytes(r.take(4)?.try_into().expect("4"));
|
|
OperationKind::TransposeInterval(TransposeIntervalOp {
|
|
targets: targets.into_iter().collect::<BTreeSet<PitchId>>(),
|
|
interval: TranspositionInterval {
|
|
diatonic_steps,
|
|
chromatic_steps,
|
|
},
|
|
})
|
|
}
|
|
31 => OperationKind::CreateInstrument(CreateInstrumentOp {
|
|
instrument: value::<Instrument>(r, "Instrument")?,
|
|
}),
|
|
32 => OperationKind::SetCanvasLayoutDefaults(SetCanvasLayoutDefaultsOp {
|
|
layout_defaults: value::<CanvasLayoutDefaults>(r, "CanvasLayoutDefaults")?,
|
|
}),
|
|
33 => OperationKind::SetSpellingPrecedence(SetSpellingPrecedenceOp {
|
|
precedence: value::<SpellingPrecedence>(r, "SpellingPrecedence")?,
|
|
}),
|
|
34 => OperationKind::SetTuningContext(SetTuningContextOp {
|
|
settings: value::<TuningContextSettings>(r, "TuningContextSettings")?,
|
|
}),
|
|
35 => OperationKind::CreateStaffGroup(CreateStaffGroupOp {
|
|
group: value::<StaffGroup>(r, "StaffGroup")?,
|
|
}),
|
|
36 => OperationKind::CreatePartDefinition(CreatePartDefinitionOp {
|
|
part: value::<PartDefinition>(r, "PartDefinition")?,
|
|
}),
|
|
37 => OperationKind::CreateAnalysisLayer(CreateAnalysisLayerOp {
|
|
layer: value::<AnalysisLayer>(r, "AnalysisLayer")?,
|
|
}),
|
|
38 => OperationKind::CreateView(CreateViewOp {
|
|
view: value::<ViewDefinition>(r, "ViewDefinition")?,
|
|
}),
|
|
39 => OperationKind::CreateMeasure(CreateMeasureOp {
|
|
instance: staff_instance_id(r)?,
|
|
measure: value::<Measure>(r, "Measure")?,
|
|
}),
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "OperationKind",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn payload(r: &mut Reader<'_>) -> Result<OperationPayload> {
|
|
Ok(match r.byte()? {
|
|
0 => OperationPayload::Primitive(operation_kind(r)?),
|
|
1 => OperationPayload::ResolveConflict(ResolveConflictPayload {
|
|
target: ConflictId(u128_id(r)?),
|
|
action: resolution_action(r)?,
|
|
}),
|
|
2 => OperationPayload::UndoTransaction(UndoTransactionPayload {
|
|
target: transaction_id(r)?,
|
|
policy: undo_policy(r)?,
|
|
}),
|
|
3 => OperationPayload::ResolveEquivocation(ResolveEquivocationPayload {
|
|
target: operation_id(r)?,
|
|
chosen: EnvelopeHash(r.take(32)?.try_into().expect("32")),
|
|
}),
|
|
tag => {
|
|
return Err(EnvelopeDecodeError::InvalidTag {
|
|
kind: "OperationPayload",
|
|
tag,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Decodes an [`OperationEnvelope`] from its canonical bytes.
|
|
///
|
|
/// Strict: an accepted byte string re-encodes to exactly itself. This is the
|
|
/// inverse the format was missing — with it, a bundle's operation blocks become
|
|
/// an [`OperationSet`](crate::OperationSet) again.
|
|
pub fn decode_envelope(bytes: &[u8]) -> Result<OperationEnvelope> {
|
|
let mut r = Reader::new(bytes);
|
|
let id = operation_id(&mut r)?;
|
|
let author = AuthorId(u128_id(&mut r)?);
|
|
let stamp = stamp(&mut r)?;
|
|
let causal_context = causal_context(&mut r)?;
|
|
let transaction = opt(&mut r, transaction_id)?;
|
|
let payload = payload(&mut r)?;
|
|
r.finish()?;
|
|
|
|
let envelope = OperationEnvelope {
|
|
id,
|
|
author,
|
|
stamp,
|
|
causal_context,
|
|
transaction,
|
|
payload,
|
|
};
|
|
// Complete-by-construction strictness: every sequence in this encoding is
|
|
// normalized by its encoder, so a non-canonical input cannot survive a round
|
|
// trip. (Where an encoder writes a `Vec` verbatim this guard is blind; those
|
|
// fields carry per-site checks above.)
|
|
if envelope.to_canonical_bytes() != bytes {
|
|
return Err(EnvelopeDecodeError::NonCanonical);
|
|
}
|
|
Ok(envelope)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
use crate::valuegen;
|
|
use epiphany_core::{BeamId, MusicalDuration, RationalTime, SlurId, StaffId, TimeSignatureId};
|
|
use epiphany_determinism::fuzz::SplitMix64;
|
|
|
|
fn ev(n: u64) -> EventId {
|
|
EventId::new(ReplicaId(7), n)
|
|
}
|
|
fn pi(n: u64) -> PitchId {
|
|
PitchId::new(ReplicaId(7), n)
|
|
}
|
|
fn rg() -> RegionId {
|
|
RegionId::new(ReplicaId(7), 1)
|
|
}
|
|
fn si() -> StaffInstanceId {
|
|
StaffInstanceId::new(ReplicaId(7), 1)
|
|
}
|
|
fn pos(n: i32) -> MusicalPosition {
|
|
MusicalPosition(RationalTime::from_int(n))
|
|
}
|
|
|
|
/// One representative operation per tag. The `match` is **exhaustive over
|
|
/// `OperationKindTag`**, so a new kind cannot be added without giving the
|
|
/// round-trip a sample of it — which is the compile-time half of the same
|
|
/// guarantee `operation_kind_tag_vocabulary!` gives the decoder.
|
|
pub(crate) fn sample_kind(tag: OperationKindTag) -> OperationKind {
|
|
match tag {
|
|
OperationKindTag::InsertEvent => OperationKind::InsertEvent(InsertEventOp {
|
|
staff_instance: si(),
|
|
event: valuegen::insert_event_value(
|
|
ev(1),
|
|
VoiceId::new(ReplicaId(7), 1),
|
|
pos(0),
|
|
MusicalDuration::whole(),
|
|
&[pi(1)],
|
|
),
|
|
}),
|
|
OperationKindTag::DeleteEvent => OperationKind::DeleteEvent(DeleteEventOp {
|
|
event: ev(1),
|
|
tuplet_compensation: TupletCompensation::RewriteTuplets {
|
|
tuplets: vec![TupletId::new(ReplicaId(7), 1)],
|
|
},
|
|
}),
|
|
OperationKindTag::RespellPitch => OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: pi(1),
|
|
spelling: valuegen::spelling(2),
|
|
}),
|
|
OperationKindTag::CreateCrossCutting => {
|
|
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
|
structure: CrossCuttingValue::Beam(valuegen::beam(
|
|
BeamId::new(ReplicaId(7), 1),
|
|
vec![ev(1), ev(2)],
|
|
)),
|
|
})
|
|
}
|
|
// The generator never emits this one, so its `PositionRemapping`
|
|
// decoder was reached by nothing until this sample existed.
|
|
OperationKindTag::ChangeRegionTimeModel => {
|
|
OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
|
|
region: rg(),
|
|
new_time_model: valuegen::proportional_model(),
|
|
declared_incompatible: vec![ev(1), ev(2)],
|
|
remapping: PositionRemapping::Reassign(vec![(ev(1), pos(0)), (ev(2), pos(4))]),
|
|
})
|
|
}
|
|
OperationKindTag::SetUserSystemBreak => {
|
|
OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
|
region: rg(),
|
|
anchor: valuegen::region_start_anchor(rg(), pos(4)),
|
|
present: true,
|
|
})
|
|
}
|
|
// Nor this one: its NFC string and optional category were untested.
|
|
OperationKindTag::DeclareTransaction => {
|
|
OperationKind::DeclareTransaction(TransactionDescriptor {
|
|
id: TransactionId::new(ReplicaId(7), 1),
|
|
label: "a transaction".to_string(),
|
|
category: Some(TransactionCategory::NoteEntry),
|
|
})
|
|
}
|
|
OperationKindTag::Registered(_) => OperationKind::Registered(
|
|
OperationKindRegistryId(0x0123_4567_89AB_CDEF),
|
|
vec![1, 2, 3, 4],
|
|
),
|
|
OperationKindTag::ModifyEvent => OperationKind::ModifyEvent(ModifyEventOp {
|
|
event: valuegen::insert_event_value(
|
|
ev(1),
|
|
VoiceId::new(ReplicaId(7), 1),
|
|
pos(0),
|
|
MusicalDuration::whole(),
|
|
&[],
|
|
),
|
|
}),
|
|
OperationKindTag::Transpose => OperationKind::Transpose(TransposeOp {
|
|
targets: vec![pi(1), pi(2)],
|
|
chromatic_steps: -3,
|
|
}),
|
|
OperationKindTag::TransposeInterval => {
|
|
OperationKind::TransposeInterval(TransposeIntervalOp {
|
|
targets: [pi(1), pi(2)].into_iter().collect(),
|
|
interval: TranspositionInterval {
|
|
diatonic_steps: 4,
|
|
chromatic_steps: 7,
|
|
},
|
|
})
|
|
}
|
|
OperationKindTag::InsertIdentifiedPitch => {
|
|
OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
|
event: ev(1),
|
|
pitch: valuegen::identified_pitch(pi(1)),
|
|
})
|
|
}
|
|
OperationKindTag::DeleteIdentifiedPitch => {
|
|
OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch: pi(1) })
|
|
}
|
|
OperationKindTag::ModifyIdentifiedPitch => {
|
|
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
|
pitch: pi(1),
|
|
value: valuegen::pitch_value(),
|
|
})
|
|
}
|
|
OperationKindTag::DeleteCrossCutting => {
|
|
OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
|
|
structure: TypedObjectId::Slur(SlurId::new(ReplicaId(7), 1)),
|
|
})
|
|
}
|
|
OperationKindTag::ModifyCrossCutting => {
|
|
OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
|
|
structure: CrossCuttingValue::Slur(valuegen::slur(
|
|
SlurId::new(ReplicaId(7), 1),
|
|
ev(1),
|
|
ev(2),
|
|
)),
|
|
})
|
|
}
|
|
OperationKindTag::InsertRegion => OperationKind::CreateRegion(CreateRegionOp {
|
|
region: valuegen::region(rg()),
|
|
}),
|
|
OperationKindTag::DeleteRegion => {
|
|
OperationKind::DeleteRegion(DeleteRegionOp { region: rg() })
|
|
}
|
|
OperationKindTag::InsertStaffInstance => {
|
|
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
|
|
region: rg(),
|
|
instance: valuegen::staff_instance(si(), StaffId::new(ReplicaId(7), 1)),
|
|
})
|
|
}
|
|
OperationKindTag::DeleteStaffInstance => {
|
|
OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
|
|
staff_instance: si(),
|
|
})
|
|
}
|
|
OperationKindTag::CreateVoice => OperationKind::CreateVoice(CreateVoiceOp {
|
|
staff_instance: si(),
|
|
voice: valuegen::voice(VoiceId::new(ReplicaId(7), 1)),
|
|
}),
|
|
OperationKindTag::DeleteVoice => OperationKind::DeleteVoice(DeleteVoiceOp {
|
|
voice: VoiceId::new(ReplicaId(7), 1),
|
|
}),
|
|
OperationKindTag::SetMetadata => OperationKind::SetMetadata(SetMetadataOp {
|
|
metadata: valuegen::score_metadata(1),
|
|
}),
|
|
OperationKindTag::SetMetricGrid => OperationKind::SetMetricGrid(SetMetricGridOp {
|
|
region: rg(),
|
|
grid: Some(valuegen::metric_grid()),
|
|
}),
|
|
OperationKindTag::SetUserPageBreak => {
|
|
OperationKind::SetUserPageBreak(SetUserPageBreakOp {
|
|
region: rg(),
|
|
anchor: valuegen::region_start_anchor(rg(), pos(8)),
|
|
present: false,
|
|
})
|
|
}
|
|
OperationKindTag::InsertStaff => OperationKind::CreateStaff(CreateStaffOp {
|
|
staff: valuegen::staff(
|
|
StaffId::new(ReplicaId(7), 1),
|
|
InstrumentId::new(ReplicaId(7), 1),
|
|
),
|
|
}),
|
|
OperationKindTag::SetTimeSignature => {
|
|
OperationKind::SetTimeSignature(SetTimeSignatureOp {
|
|
region: rg(),
|
|
anchor: valuegen::region_start_anchor(rg(), pos(0)),
|
|
time_signature: Some(valuegen::time_signature(
|
|
TimeSignatureId::new(ReplicaId(7), 1),
|
|
3,
|
|
)),
|
|
})
|
|
}
|
|
OperationKindTag::SetTempoSegment => {
|
|
OperationKind::SetTempoSegment(SetTempoSegmentOp {
|
|
region: Some(rg()),
|
|
start: valuegen::region_start_anchor(rg(), pos(0)),
|
|
segment: Some(valuegen::tempo_segment(rg(), pos(0), 90.0)),
|
|
})
|
|
}
|
|
OperationKindTag::SetStaffLayout => OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
|
staff_instance: si(),
|
|
instrument_override: Some(InstrumentId::new(ReplicaId(7), 1)),
|
|
staff_lines_override: Some(StaffLineConfiguration::default()),
|
|
visible: true,
|
|
}),
|
|
OperationKindTag::CreateRepeatStructure => {
|
|
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
|
|
repeat: valuegen::volta_repeat(
|
|
RepeatStructureId::new(ReplicaId(7), 1),
|
|
ev(1),
|
|
ev(2),
|
|
),
|
|
})
|
|
}
|
|
OperationKindTag::DeleteRepeatStructure => {
|
|
OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
|
|
repeat: RepeatStructureId::new(ReplicaId(7), 1),
|
|
})
|
|
}
|
|
OperationKindTag::CreateInstrument => {
|
|
OperationKind::CreateInstrument(CreateInstrumentOp {
|
|
instrument: valuegen::instrument(InstrumentId::new(ReplicaId(7), 1)),
|
|
})
|
|
}
|
|
OperationKindTag::SetCanvasLayoutDefaults => {
|
|
OperationKind::SetCanvasLayoutDefaults(SetCanvasLayoutDefaultsOp {
|
|
layout_defaults: valuegen::canvas_layout_defaults(1),
|
|
})
|
|
}
|
|
OperationKindTag::SetSpellingPrecedence => {
|
|
OperationKind::SetSpellingPrecedence(SetSpellingPrecedenceOp {
|
|
precedence: valuegen::spelling_precedence(1),
|
|
})
|
|
}
|
|
OperationKindTag::SetTuningContext => {
|
|
OperationKind::SetTuningContext(SetTuningContextOp {
|
|
settings: valuegen::tuning_context_settings(1),
|
|
})
|
|
}
|
|
OperationKindTag::CreateStaffGroup => {
|
|
OperationKind::CreateStaffGroup(crate::payload::CreateStaffGroupOp {
|
|
group: valuegen::staff_group(
|
|
epiphany_core::StaffGroupId::new(ReplicaId(7), 1),
|
|
vec![StaffId::new(ReplicaId(7), 1)],
|
|
),
|
|
})
|
|
}
|
|
OperationKindTag::CreatePartDefinition => {
|
|
OperationKind::CreatePartDefinition(crate::payload::CreatePartDefinitionOp {
|
|
part: valuegen::part_definition(
|
|
epiphany_core::PartDefinitionId::new(ReplicaId(7), 1),
|
|
vec![StaffId::new(ReplicaId(7), 1)],
|
|
),
|
|
})
|
|
}
|
|
OperationKindTag::CreateAnalysisLayer => {
|
|
OperationKind::CreateAnalysisLayer(crate::payload::CreateAnalysisLayerOp {
|
|
layer: valuegen::analysis_layer(epiphany_core::AnalysisLayerId::new(
|
|
ReplicaId(7),
|
|
1,
|
|
)),
|
|
})
|
|
}
|
|
OperationKindTag::CreateView => {
|
|
OperationKind::CreateView(crate::payload::CreateViewOp {
|
|
view: valuegen::view(
|
|
epiphany_core::ViewId::new(ReplicaId(7), 1),
|
|
vec![epiphany_core::AnalysisLayerId::new(ReplicaId(7), 1)],
|
|
),
|
|
})
|
|
}
|
|
OperationKindTag::CreateMeasure => {
|
|
OperationKind::CreateMeasure(crate::payload::CreateMeasureOp {
|
|
instance: si(),
|
|
measure: valuegen::measure(
|
|
epiphany_core::MeasureId::new(ReplicaId(7), 1),
|
|
epiphany_core::TimeSignatureId::new(ReplicaId(7), 1),
|
|
1,
|
|
),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn envelope(payload: OperationPayload) -> OperationEnvelope {
|
|
let id = OperationId::new(ReplicaId(7), 1);
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(0x1122_3344),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(42), 7), id),
|
|
causal_context: CausalContext::new()
|
|
.with_seen(ReplicaId(1), 3)
|
|
.with_dot(OperationId::new(ReplicaId(2), 9)),
|
|
transaction: Some(TransactionId::new(ReplicaId(7), 5)),
|
|
payload,
|
|
}
|
|
}
|
|
|
|
fn assert_round_trips(env: &OperationEnvelope) {
|
|
let bytes = env.to_canonical_bytes();
|
|
let decoded = decode_envelope(&bytes)
|
|
.unwrap_or_else(|e| panic!("{:?} must decode: {e}", env.payload));
|
|
assert_eq!(&decoded, env, "value round-trip");
|
|
assert_eq!(decoded.to_canonical_bytes(), bytes, "byte round-trip");
|
|
}
|
|
|
|
/// Every operation kind, and every payload variant, round-trips.
|
|
///
|
|
/// Driven from `OperationKindTag::PAYLOAD_FREE` plus `Registered`, so it
|
|
/// cannot silently stop covering the vocabulary.
|
|
#[test]
|
|
fn every_operation_kind_and_payload_round_trips() {
|
|
for tag in OperationKindTag::PAYLOAD_FREE {
|
|
assert_round_trips(&envelope(OperationPayload::Primitive(sample_kind(*tag))));
|
|
}
|
|
assert_round_trips(&envelope(OperationPayload::Primitive(sample_kind(
|
|
OperationKindTag::Registered(OperationKindRegistryId(1)),
|
|
))));
|
|
|
|
// The three meta payloads. `gen_envelope_set` emits none of them, so
|
|
// their decoders were reached by nothing.
|
|
assert_round_trips(&envelope(OperationPayload::ResolveConflict(
|
|
ResolveConflictPayload {
|
|
target: ConflictId(0xDEAD_BEEF),
|
|
action: ResolutionAction::Reanchor {
|
|
new_target: TypedObjectId::Event(ev(3)),
|
|
},
|
|
},
|
|
)));
|
|
assert_round_trips(&envelope(OperationPayload::UndoTransaction(
|
|
UndoTransactionPayload {
|
|
target: TransactionId::new(ReplicaId(7), 5),
|
|
policy: UndoPolicy::BestEffort,
|
|
},
|
|
)));
|
|
assert_round_trips(&envelope(OperationPayload::ResolveEquivocation(
|
|
ResolveEquivocationPayload {
|
|
target: OperationId::new(ReplicaId(7), 2),
|
|
chosen: EnvelopeHash([9; 32]),
|
|
},
|
|
)));
|
|
}
|
|
|
|
/// Replaces the **last** occurrence of `find` with `with` (equal lengths).
|
|
///
|
|
/// Last, not first: a `PitchId` and an `OperationId` with the same replica
|
|
/// and counter have identical canonical bytes, so `pi(1)` also matches the
|
|
/// envelope's own leading id. The targets are the last thing in these
|
|
/// payloads.
|
|
fn patch(bytes: &[u8], find: &[u8], with: &[u8]) -> Vec<u8> {
|
|
assert_eq!(find.len(), with.len());
|
|
let at = bytes
|
|
.windows(find.len())
|
|
.rposition(|w| w == find)
|
|
.expect("the needle occurs");
|
|
let mut out = bytes.to_vec();
|
|
out[at..at + find.len()].copy_from_slice(with);
|
|
out
|
|
}
|
|
|
|
/// `TransposeInterval.targets` is a **set**: `seq^⇑`, strictly increasing.
|
|
/// A duplicate is rejected, never silently absorbed by the `BTreeSet` it
|
|
/// collects into — that is the rule Push 4a wrote into the wire table and
|
|
/// left for whoever built this decoder.
|
|
#[test]
|
|
fn transpose_interval_targets_reject_duplicates_and_disorder() {
|
|
let env = envelope(OperationPayload::Primitive(sample_kind(
|
|
OperationKindTag::TransposeInterval,
|
|
)));
|
|
let bytes = env.to_canonical_bytes();
|
|
assert!(decode_envelope(&bytes).is_ok());
|
|
|
|
let (a, b) = (pi(1).to_canonical_bytes(), pi(2).to_canonical_bytes());
|
|
|
|
// [p1, p1]: a duplicate. Collecting into a `BTreeSet` would swallow it.
|
|
let duplicated = patch(&bytes, &b, &a);
|
|
assert_eq!(
|
|
decode_envelope(&duplicated),
|
|
Err(EnvelopeDecodeError::TargetsNotStrictlyIncreasing)
|
|
);
|
|
|
|
// [p2, p1]: out of order. A `BTreeSet` would re-sort it.
|
|
let swapped = patch(&patch(&bytes, &a, &[0xEE; 16]), &b, &a);
|
|
let swapped = patch(&swapped, &[0xEE; 16], &b);
|
|
assert_eq!(
|
|
decode_envelope(&swapped),
|
|
Err(EnvelopeDecodeError::TargetsNotStrictlyIncreasing)
|
|
);
|
|
}
|
|
|
|
/// The frozen `Transpose` is a **multiset**: non-decreasing, duplicates
|
|
/// preserved. Its wire form must keep decoding exactly as it always has.
|
|
#[test]
|
|
fn the_frozen_transpose_accepts_duplicate_targets_but_not_disorder() {
|
|
let env = envelope(OperationPayload::Primitive(sample_kind(
|
|
OperationKindTag::Transpose,
|
|
)));
|
|
let bytes = env.to_canonical_bytes();
|
|
let (a, b) = (pi(1).to_canonical_bytes(), pi(2).to_canonical_bytes());
|
|
|
|
// [p1, p1]: a duplicate is legal here, and must survive the round trip.
|
|
let duplicated = patch(&bytes, &b, &a);
|
|
let decoded = decode_envelope(&duplicated).expect("a multiset accepts duplicates");
|
|
match &decoded.payload {
|
|
OperationPayload::Primitive(OperationKind::Transpose(op)) => {
|
|
assert_eq!(op.targets, vec![pi(1), pi(1)], "both targets survive");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(decoded.to_canonical_bytes(), duplicated);
|
|
|
|
// [p2, p1]: still not canonical.
|
|
let swapped = patch(&patch(&bytes, &a, &[0xEE; 16]), &b, &a);
|
|
let swapped = patch(&swapped, &[0xEE; 16], &b);
|
|
assert_eq!(
|
|
decode_envelope(&swapped),
|
|
Err(EnvelopeDecodeError::TargetsNotSorted)
|
|
);
|
|
}
|
|
|
|
/// `ChangeRegionTimeModel.declared_incompatible` is written **sorted** by its
|
|
/// encoder and read back verbatim, so an unsorted encoding decodes fine and
|
|
/// only the whole-envelope re-encode guard rejects it.
|
|
///
|
|
/// Nothing else can: the round-trip tests feed canonical bytes by
|
|
/// construction, so removing the guard leaves them green (verified). This is
|
|
/// the test that locks it.
|
|
#[test]
|
|
fn an_unsorted_sequence_is_rejected_by_the_whole_envelope_guard() {
|
|
let env = envelope(OperationPayload::Primitive(sample_kind(
|
|
OperationKindTag::ChangeRegionTimeModel,
|
|
)));
|
|
let bytes = env.to_canonical_bytes();
|
|
assert!(decode_envelope(&bytes).is_ok());
|
|
|
|
// `declared_incompatible` is a `push_seq`: count, then each element as
|
|
// `u32` length + 16 bytes. Locate the two framed elements together —
|
|
// a bare id search would hit the envelope's own leading id, which has
|
|
// the same canonical bytes as `ev(1)`.
|
|
let (a, b) = (ev(1).to_canonical_bytes(), ev(2).to_canonical_bytes());
|
|
let mut framed = Vec::new();
|
|
framed.extend_from_slice(&16u32.to_le_bytes());
|
|
framed.extend_from_slice(&a);
|
|
framed.extend_from_slice(&16u32.to_le_bytes());
|
|
framed.extend_from_slice(&b);
|
|
let at = bytes
|
|
.windows(framed.len())
|
|
.position(|w| w == framed)
|
|
.expect("the framed pair occurs exactly here");
|
|
|
|
let mut swapped = bytes.clone();
|
|
swapped[at + 4..at + 20].copy_from_slice(&b);
|
|
swapped[at + 24..at + 40].copy_from_slice(&a);
|
|
assert_ne!(swapped, bytes);
|
|
|
|
assert_eq!(
|
|
decode_envelope(&swapped),
|
|
Err(EnvelopeDecodeError::NonCanonical),
|
|
"an unsorted sequence must be rejected, never silently re-sorted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_decoder_rejects_trailing_truncated_and_garbage_counts() {
|
|
let env = envelope(OperationPayload::Primitive(sample_kind(
|
|
OperationKindTag::DeleteRegion,
|
|
)));
|
|
let bytes = env.to_canonical_bytes();
|
|
assert!(decode_envelope(&bytes).is_ok());
|
|
|
|
let mut trailing = bytes.clone();
|
|
trailing.push(0);
|
|
assert_eq!(
|
|
decode_envelope(&trailing),
|
|
Err(EnvelopeDecodeError::TrailingBytes)
|
|
);
|
|
|
|
let truncated = &bytes[..bytes.len() - 1];
|
|
assert!(decode_envelope(truncated).is_err());
|
|
|
|
// The causal context's vector count sits at a known offset: id(16) +
|
|
// author(16) + stamp(12+16). A garbage count must be rejected against
|
|
// the bytes remaining, never pre-allocated for.
|
|
let mut huge = bytes.clone();
|
|
huge[60..64].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
assert!(matches!(
|
|
decode_envelope(&huge),
|
|
Err(EnvelopeDecodeError::CountExceedsRemaining { .. })
|
|
));
|
|
}
|
|
|
|
/// Every envelope the generator can produce round-trips, at scale.
|
|
///
|
|
/// This is 4,000 envelopes and it reaches only 28 of 31 kinds and one of
|
|
/// four payload variants — which is why the exhaustive test above exists.
|
|
/// The coverage is asserted so the gap cannot widen unnoticed.
|
|
#[test]
|
|
fn every_generated_envelope_round_trips() {
|
|
let mut rng = SplitMix64::new(0x0DEC_0DE0_E4E1_0001);
|
|
let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 4_000);
|
|
let mut kinds = std::collections::BTreeSet::new();
|
|
for env in &envelopes {
|
|
if let OperationPayload::Primitive(k) = &env.payload {
|
|
kinds.insert(k.tag());
|
|
}
|
|
assert_round_trips(env);
|
|
}
|
|
assert!(
|
|
kinds.len() >= 28,
|
|
"the generator reached only {} kinds",
|
|
kinds.len()
|
|
);
|
|
}
|
|
}
|