Skip to main content

truce_utils/
state.rs

1//! Canonical plugin-state wire format.
2//!
3//! Lives in `truce-utils` (rather than `truce-core`) so build-time
4//! tools - `cargo-truce` emits factory-preset files containing this
5//! envelope at install time - can produce byte-identical blobs
6//! without inheriting `truce-core`'s runtime dependency chain.
7//! `truce-core::state` re-exports everything here and layers the
8//! plugin-coupled helpers (`apply_state`, `snapshot_plugin`, ...)
9//! on top.
10
11/// Magic bytes for state identification.
12const STATE_MAGIC: &[u8; 4] = b"OAST";
13const STATE_VERSION: u32 = 1;
14
15/// Serialize plugin state: parameter values + extra state. Empty
16/// `extra` slice serializes as the same `0u64` length-prefix that an
17/// absent extra block would, so callers don't need an `Option`
18/// wrapper to express "no extra state".
19#[must_use]
20pub fn serialize_state(
21    plugin_id_hash: u64,
22    param_ids: &[u32],
23    param_values: &[f64],
24    extra: &[u8],
25    persist: &[u8],
26) -> Vec<u8> {
27    let mut data = Vec::new();
28
29    // Header
30    data.extend_from_slice(STATE_MAGIC);
31    data.extend_from_slice(&STATE_VERSION.to_le_bytes());
32    data.extend_from_slice(&plugin_id_hash.to_le_bytes());
33
34    // Parameter block
35    let count = crate::cast::len_u32(param_ids.len());
36    data.extend_from_slice(&count.to_le_bytes());
37    for (id, value) in param_ids.iter().zip(param_values.iter()) {
38        data.extend_from_slice(&id.to_le_bytes());
39        data.extend_from_slice(&value.to_le_bytes());
40    }
41
42    // Extra state block: length-prefixed, may be zero-length.
43    data.extend_from_slice(&(extra.len() as u64).to_le_bytes());
44    data.extend_from_slice(extra);
45
46    // `#[persist]` block: length-prefixed, appended after `extra`. A
47    // trailing block so envelopes written before persistence existed
48    // (which stop after `extra`) still parse - `parse_state` reads it
49    // only when bytes remain, defaulting to empty.
50    data.extend_from_slice(&(persist.len() as u64).to_le_bytes());
51    data.extend_from_slice(persist);
52
53    data
54}
55
56/// Deserialized state.
57pub struct DeserializedState {
58    pub params: Vec<(u32, f64)>,
59    pub extra: Option<Vec<u8>>,
60    /// `#[persist]`-field bytes (a keyed blob the `Params` derive
61    /// produces). Empty when the plugin has no persist fields, or the
62    /// envelope predates persistence.
63    pub persist: Vec<u8>,
64}
65
66/// Outcome of [`parse_state`]: what the bytes turned out to be.
67/// Splits the causes [`deserialize_state`] collapses into one `None`
68/// so callers can route each one differently (fail the load, offer
69/// the bytes to the plugin's `migrate_state` hook, ...).
70pub enum StateParse {
71    Ok(DeserializedState),
72    /// Wrong magic / too short to carry the header: not a truce
73    /// envelope at all. Candidate for legacy-state migration.
74    NotAnEnvelope,
75    /// Valid magic, `version` field newer than this build understands.
76    /// Reserved for truce-side envelope evolution: never handed to
77    /// the plugin, the load fails with a distinct log line.
78    UnknownVersion(u32),
79    /// Structurally valid envelope whose `plugin_id_hash` isn't the
80    /// expected one - a renamed / re-identified plugin. Params and
81    /// extra are already decoded; the caller decides whether to
82    /// offer them to the plugin.
83    WrongPlugin {
84        found: u64,
85        state: DeserializedState,
86    },
87    /// Magic + version matched but the param / extra blocks are
88    /// truncated or inconsistent. Never migrated, the load fails.
89    Corrupt,
90}
91
92/// Deserialize plugin state. Thin wrapper over [`parse_state`] for
93/// callers that only care about the strict-success case (preset
94/// codecs, tests).
95#[must_use]
96pub fn deserialize_state(data: &[u8], expected_plugin_id: u64) -> Option<DeserializedState> {
97    match parse_state(data, expected_plugin_id) {
98        StateParse::Ok(state) => Some(state),
99        _ => None,
100    }
101}
102
103/// Parse a state blob, distinguishing every failure cause. See
104/// [`StateParse`] for the routing each variant is meant for.
105#[must_use]
106pub fn parse_state(data: &[u8], expected_plugin_id: u64) -> StateParse {
107    if data.len() < 8 || &data[0..4] != STATE_MAGIC {
108        return StateParse::NotAnEnvelope;
109    }
110
111    let Ok(version_bytes) = data[4..8].try_into() else {
112        return StateParse::NotAnEnvelope;
113    };
114    let version = u32::from_le_bytes(version_bytes);
115    if version != STATE_VERSION {
116        return StateParse::UnknownVersion(version);
117    }
118
119    // From here on the bytes claim to be a current-version envelope:
120    // any structural violation is corruption, not foreign data.
121    let Some(id_bytes) = data.get(8..16) else {
122        return StateParse::Corrupt;
123    };
124    let Ok(id_bytes) = id_bytes.try_into() else {
125        return StateParse::Corrupt;
126    };
127    let plugin_id = u64::from_le_bytes(id_bytes);
128
129    let mut offset = 16;
130
131    // Parameter block
132    if offset + 4 > data.len() {
133        return StateParse::Corrupt;
134    }
135    let Ok(count_bytes) = data[offset..offset + 4].try_into() else {
136        return StateParse::Corrupt;
137    };
138    let count = u32::from_le_bytes(count_bytes) as usize;
139    offset += 4;
140
141    // Cap the pre-allocation by what the remaining buffer could
142    // possibly hold. Each entry is 12 bytes (`u32 id` + `f64 value`),
143    // so a hostile or corrupted blob with `count = u32::MAX` (≈64 GB
144    // request) is clamped to at most the remaining byte budget. The
145    // per-iteration bounds check below still rejects entries that
146    // overrun the buffer; this just keeps the up-front allocation
147    // honest.
148    let max_count = data.len().saturating_sub(offset) / 12;
149    let mut params = Vec::with_capacity(count.min(max_count));
150    for _ in 0..count {
151        if offset + 12 > data.len() {
152            return StateParse::Corrupt;
153        }
154        let Ok(id_bytes) = data[offset..offset + 4].try_into() else {
155            return StateParse::Corrupt;
156        };
157        let id = u32::from_le_bytes(id_bytes);
158        offset += 4;
159        let Ok(value_bytes) = data[offset..offset + 8].try_into() else {
160            return StateParse::Corrupt;
161        };
162        let value = f64::from_le_bytes(value_bytes);
163        offset += 8;
164        params.push((id, value));
165    }
166
167    // Extra state block
168    if offset + 8 > data.len() {
169        return StateParse::Corrupt;
170    }
171    let Ok(len_bytes) = data[offset..offset + 8].try_into() else {
172        return StateParse::Corrupt;
173    };
174    // The wire format encodes `extra_len` as `u64`; on 32-bit
175    // targets the cast may truncate, but the next branch validates
176    // `offset.checked_add(extra_len)` against the buffer length.
177    #[allow(clippy::cast_possible_truncation)]
178    let extra_len = u64::from_le_bytes(len_bytes) as usize;
179    offset += 8;
180
181    let extra = if extra_len > 0 {
182        // `offset + extra_len` can wrap to a small value when
183        // `extra_len` is huge (host-supplied), making the comparison
184        // pass even though the slice would overrun. Use `checked_add`
185        // and reject overflow as malformed.
186        match offset.checked_add(extra_len) {
187            Some(end) if end <= data.len() => Some(data[offset..end].to_vec()),
188            _ => return StateParse::Corrupt,
189        }
190    } else {
191        None
192    };
193    offset += extra_len;
194
195    // `#[persist]` block: trailing and optional. Absent (older envelope,
196    // or no persist fields) when no bytes remain - then it's empty.
197    let persist = if offset + 8 <= data.len() {
198        let Ok(len_bytes) = data[offset..offset + 8].try_into() else {
199            return StateParse::Corrupt;
200        };
201        #[allow(clippy::cast_possible_truncation)]
202        let persist_len = u64::from_le_bytes(len_bytes) as usize;
203        offset += 8;
204        match offset.checked_add(persist_len) {
205            Some(end) if end <= data.len() => data[offset..end].to_vec(),
206            _ => return StateParse::Corrupt,
207        }
208    } else {
209        Vec::new()
210    };
211
212    let state = DeserializedState {
213        params,
214        extra,
215        persist,
216    };
217    if plugin_id == expected_plugin_id {
218        StateParse::Ok(state)
219    } else {
220        StateParse::WrongPlugin {
221            found: plugin_id,
222            state,
223        }
224    }
225}
226
227/// Compute a simple hash of the plugin ID string for state identification.
228///
229/// Uses FNV-1a-64. **Do not change this without bumping the envelope's
230/// `STATE_VERSION` and writing a migration:** the returned hash is
231/// stored verbatim in every `.pluginstate` blob the host has saved,
232/// and a different algorithm here would invalidate every shipped
233/// session. If a stronger hash is ever needed, it must be selected via
234/// the version byte in the envelope, not by replacing this function in
235/// place.
236#[must_use]
237pub fn hash_plugin_id(id: &str) -> u64 {
238    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
239    for byte in id.bytes() {
240        hash ^= u64::from(byte);
241        hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
242    }
243    hash
244}
245
246/// The 16-byte VST3 class ID for a plugin ID string.
247///
248/// FNV-1a-128, per <http://www.isthe.com/chongo/tech/comp/fnv/>.
249/// Standard constants - DAWs persist this CID as the plugin's identity
250/// in saved sessions and `.vstpreset` files, so the algorithm and
251/// constants must stay stable across releases. `truce-vst3` reports
252/// this as the component's TUID; `cargo-truce` stamps the same bytes
253/// (hex-encoded) into emitted `.vstpreset` headers.
254#[must_use]
255pub fn vst3_cid(id: &str) -> [u8; 16] {
256    const FNV_OFFSET_BASIS: u128 = 0x6C62_272E_07BB_0142_62B8_2175_6295_C58D;
257    const FNV_PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013B;
258    let mut hash = FNV_OFFSET_BASIS;
259    for byte in id.bytes() {
260        hash ^= u128::from(byte);
261        hash = hash.wrapping_mul(FNV_PRIME);
262    }
263    hash.to_le_bytes()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn round_trip_state() {
272        let plugin_id = hash_plugin_id("com.test.plugin");
273        let ids = [0u32, 1, 2];
274        let values = [0.5f64, 1.0, -12.0];
275        let extra = b"hello extra state";
276
277        let data = serialize_state(plugin_id, &ids, &values, extra, &[]);
278        let state = deserialize_state(&data, plugin_id).unwrap();
279
280        assert_eq!(state.params.len(), 3);
281        assert_eq!(state.params[0], (0, 0.5));
282        assert_eq!(state.params[1], (1, 1.0));
283        assert_eq!(state.params[2], (2, -12.0));
284        assert_eq!(state.extra.unwrap(), b"hello extra state");
285    }
286
287    #[test]
288    fn wrong_plugin_id_fails() {
289        let plugin_id = hash_plugin_id("com.test.plugin");
290        let data = serialize_state(plugin_id, &[], &[], &[], &[]);
291        assert!(deserialize_state(&data, 12345).is_none());
292    }
293
294    #[test]
295    fn parse_distinguishes_foreign_bytes() {
296        assert!(matches!(
297            parse_state(b"{\"legacy\":true}", 1),
298            StateParse::NotAnEnvelope
299        ));
300        assert!(matches!(parse_state(b"", 1), StateParse::NotAnEnvelope));
301        assert!(matches!(parse_state(b"OAS", 1), StateParse::NotAnEnvelope));
302    }
303
304    #[test]
305    fn parse_distinguishes_future_version() {
306        let mut data = serialize_state(1, &[], &[], &[], &[]);
307        data[4..8].copy_from_slice(&2u32.to_le_bytes());
308        assert!(matches!(
309            parse_state(&data, 1),
310            StateParse::UnknownVersion(2)
311        ));
312    }
313
314    #[test]
315    fn parse_decodes_wrong_plugin_envelope() {
316        let data = serialize_state(99, &[7], &[0.25], b"extra", &[]);
317        match parse_state(&data, 1) {
318            StateParse::WrongPlugin { found, state } => {
319                assert_eq!(found, 99);
320                assert_eq!(state.params, vec![(7, 0.25)]);
321                assert_eq!(state.extra.as_deref(), Some(&b"extra"[..]));
322            }
323            _ => panic!("expected WrongPlugin"),
324        }
325    }
326
327    #[test]
328    fn parse_flags_truncated_envelope_as_corrupt() {
329        let data = serialize_state(1, &[7, 8], &[0.25, 0.5], b"extra", &[]);
330        // Cut inside the param block: valid header, broken body.
331        assert!(matches!(parse_state(&data[..22], 1), StateParse::Corrupt));
332    }
333}