truce_rack_core/state.rs
1//! Versioned envelope around plugin state blobs.
2//!
3//! Plugin state coming back from `get_state` / going into
4//! `set_state` is opaque on the host side — but to defend the
5//! host against a corrupt or wrong-format blob (one was sniffed
6//! from a different plugin, one is from a future version), we
7//! wrap each blob in a host-defined envelope before persisting
8//! it to disk and unwrap it before feeding it back to the plugin.
9//!
10//! Plugin payloads themselves stay opaque — the envelope only
11//! carries metadata the host can validate without parsing the
12//! payload.
13
14/// Magic bytes prefixing every envelope. Lets a host that
15/// reads a stray file know it's looking at a rack-wrapped blob
16/// rather than the plugin's raw state.
17pub const ENVELOPE_MAGIC: &[u8; 4] = b"RKST";
18
19/// Bumped when the envelope layout itself changes (not the
20/// payload). v1 = `MAGIC | u16 version | u8 format_id | u8 pad |
21/// u32 payload_len | payload[..]`.
22pub const ENVELOPE_VERSION: u16 = 1;
23
24/// Numeric tag for the format that produced the payload.
25/// Lets the host refuse to feed a VST3 blob to a CLAP plugin.
26#[repr(u8)]
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FormatId {
29 /// Reserved sentinel — never written.
30 Unknown = 0,
31 /// CLAP.
32 Clap = 1,
33 /// VST3.
34 Vst3 = 2,
35 /// AU v2 (`auFX` / `aufx` etc.).
36 AuV2 = 3,
37 /// AU v3 (NSExtension-based).
38 AuV3 = 4,
39 /// VST2.
40 Vst2 = 5,
41 /// LV2.
42 Lv2 = 6,
43 /// AAX.
44 Aax = 7,
45}
46
47/// Failure modes when unwrapping an envelope.
48#[derive(Debug, thiserror::Error)]
49pub enum StateLoadError {
50 /// Buffer is shorter than the envelope header demands.
51 #[error("state blob too short: expected at least {expected} bytes, got {actual}")]
52 Truncated {
53 /// Minimum bytes required.
54 expected: usize,
55 /// Actual bytes available.
56 actual: usize,
57 },
58
59 /// Magic prefix didn't match — wrong file, raw plugin
60 /// state, or corruption.
61 #[error("state magic mismatch")]
62 BadMagic,
63
64 /// Envelope version is newer than this rack build knows.
65 #[error("state envelope version {found} > supported {supported}")]
66 UnsupportedVersion {
67 /// Version field in the blob.
68 found: u16,
69 /// Maximum version this rack build understands.
70 supported: u16,
71 },
72
73 /// Payload-length field disagrees with the buffer's actual
74 /// length.
75 #[error("state payload length {declared} != trailing bytes {actual}")]
76 LengthMismatch {
77 /// `payload_len` from the envelope header.
78 declared: u32,
79 /// Trailing bytes after the header in the supplied buffer.
80 actual: usize,
81 },
82
83 /// Payload is from a different format than the host expected.
84 /// The host can decide whether to surface this as an error or
85 /// try to feed it anyway (some plugins span multiple formats).
86 #[error("state format mismatch: payload is {found:?}, host expected {expected:?}")]
87 FormatMismatch {
88 /// Format tag in the envelope.
89 found: FormatId,
90 /// Format the host was loading into.
91 expected: FormatId,
92 },
93}
94
95/// Host-side wrapper around a plugin state payload.
96#[derive(Debug, Clone)]
97pub struct StateEnvelope<'a> {
98 /// Source format of the payload.
99 pub format: FormatId,
100 /// Opaque plugin bytes.
101 pub payload: &'a [u8],
102}
103
104const HEADER_LEN: usize = 4 + 2 + 1 + 1 + 4;
105
106impl StateEnvelope<'_> {
107 /// Serialize this envelope into a freshly-allocated `Vec`.
108 /// Hosts then persist the result however they like (disk
109 /// file, DAW project blob, etc.).
110 ///
111 /// Layout:
112 /// ```text
113 /// 0..4 "RKST" magic
114 /// 4..6 u16 envelope version (little-endian)
115 /// 6 u8 format id
116 /// 7 u8 reserved (must be 0)
117 /// 8..12 u32 payload length (little-endian)
118 /// 12.. payload bytes
119 /// ```
120 #[must_use]
121 pub fn encode(&self) -> Vec<u8> {
122 let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len());
123 out.extend_from_slice(ENVELOPE_MAGIC);
124 out.extend_from_slice(&ENVELOPE_VERSION.to_le_bytes());
125 out.push(self.format as u8);
126 out.push(0);
127 #[allow(clippy::cast_possible_truncation)]
128 let payload_len = self.payload.len() as u32;
129 out.extend_from_slice(&payload_len.to_le_bytes());
130 out.extend_from_slice(self.payload);
131 out
132 }
133
134 /// Parse an envelope from `bytes`. Validates magic, version,
135 /// and payload length. The returned payload borrows from
136 /// `bytes`.
137 ///
138 /// # Errors
139 /// Returns [`StateLoadError`] if the buffer is shorter than the
140 /// envelope header, the magic bytes do not match, the version
141 /// is newer than [`ENVELOPE_VERSION`], the format byte is not
142 /// one of the known [`FormatId`] values, or the declared
143 /// payload length doesn't match the remaining bytes.
144 pub fn decode(bytes: &[u8]) -> Result<StateEnvelope<'_>, StateLoadError> {
145 if bytes.len() < HEADER_LEN {
146 return Err(StateLoadError::Truncated {
147 expected: HEADER_LEN,
148 actual: bytes.len(),
149 });
150 }
151 if &bytes[0..4] != ENVELOPE_MAGIC {
152 return Err(StateLoadError::BadMagic);
153 }
154 let version = u16::from_le_bytes([bytes[4], bytes[5]]);
155 if version > ENVELOPE_VERSION {
156 return Err(StateLoadError::UnsupportedVersion {
157 found: version,
158 supported: ENVELOPE_VERSION,
159 });
160 }
161 let format = match bytes[6] {
162 1 => FormatId::Clap,
163 2 => FormatId::Vst3,
164 3 => FormatId::AuV2,
165 4 => FormatId::AuV3,
166 5 => FormatId::Vst2,
167 6 => FormatId::Lv2,
168 7 => FormatId::Aax,
169 _ => FormatId::Unknown,
170 };
171 let declared_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
172 let payload = &bytes[HEADER_LEN..];
173 if declared_len as usize != payload.len() {
174 return Err(StateLoadError::LengthMismatch {
175 declared: declared_len,
176 actual: payload.len(),
177 });
178 }
179 Ok(StateEnvelope { format, payload })
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn round_trip() {
189 let payload = b"opaque plugin state bytes";
190 let env = StateEnvelope {
191 format: FormatId::Clap,
192 payload,
193 };
194 let encoded = env.encode();
195 let decoded = StateEnvelope::decode(&encoded).expect("decode");
196 assert_eq!(decoded.format, FormatId::Clap);
197 assert_eq!(decoded.payload, payload);
198 }
199
200 #[test]
201 fn truncated_header_rejected() {
202 let short = b"RK";
203 let err = StateEnvelope::decode(short).unwrap_err();
204 assert!(matches!(err, StateLoadError::Truncated { .. }));
205 }
206
207 #[test]
208 fn bad_magic_rejected() {
209 let mut buf = b"XXXX".to_vec();
210 buf.extend(std::iter::repeat_n(0u8, HEADER_LEN));
211 let err = StateEnvelope::decode(&buf).unwrap_err();
212 assert!(matches!(err, StateLoadError::BadMagic));
213 }
214
215 #[test]
216 fn length_mismatch_rejected() {
217 let env = StateEnvelope {
218 format: FormatId::Vst3,
219 payload: b"abcd",
220 };
221 let mut buf = env.encode();
222 buf.push(0xFF); // trailing junk
223 let err = StateEnvelope::decode(&buf).unwrap_err();
224 assert!(matches!(err, StateLoadError::LengthMismatch { .. }));
225 }
226}