Skip to main content

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}