Skip to main content

truce_loader/
canary.rs

1//! ABI canary - runtime verification that shell and dylib have
2//! compatible type layouts and vtable ordering.
3
4use std::cell::RefCell;
5use std::mem::{align_of, size_of};
6use std::ptr;
7
8use truce_core::buffer::AudioBuffer;
9use truce_core::events::{Event, EventBody, EventList, TransportInfo as Transport};
10use truce_core::process::{ProcessContext, ProcessStatus};
11// Source canary types from `truce-gui-types` (the lightweight types
12// crate) and `truce-plugin` (the trait surface) so the canary - which
13// every shell needs - stays available even when `builtin-gui` is off
14// and the heavy `truce-gui` renderer crate is out of the dep graph.
15use truce_gui_types::interaction::WidgetRegion;
16use truce_gui_types::layout::GridLayout;
17use truce_gui_types::theme::{Color, Theme};
18use truce_params::sample::Sample;
19use truce_plugin::PluginLogicCore;
20
21/// Hand-bumped ABI epoch. Sizes and alignments can't see every
22/// layout change: `Event::port` landed in former padding, so
23/// `event_size` stayed the same while a stale shell would read
24/// uninitialized padding as the port. Bump this when any
25/// boundary-crossing type changes layout invisibly to the size /
26/// align fields below. When `AbiCanary` itself gains or loses a
27/// field, bump the *export symbol* version instead
28/// (`truce_abi_canary_vN`) - the canary crosses the boundary by
29/// value, so two different canary layouts must never call each
30/// other.
31///
32/// Epoch 2: `Event` grew `port: u8` in former padding (multi-port
33/// MIDI).
34/// Epoch 3: `editor` left the `PluginLogicCore` vtable (it moved to the
35/// receiverless `truce_build_editor` export). The slot's removal shifts
36/// every later vtable index, so a stale epoch-2 dylib would bind
37/// `save_state` / `latency` / `tail` to the wrong slots and lacks the
38/// new symbol; this bump rejects it cleanly at the canary instead of
39/// relying on the probe to notice the misalignment.
40pub const ABI_EPOCH: u32 = 3;
41
42/// ABI fingerprint. Compared between shell and dylib before loading.
43///
44/// This is the ONE `#[repr(C)]` type in the system - it's the
45/// bootstrap verification struct that makes everything else safe.
46#[repr(C)]
47pub struct AbiCanary {
48    /// [`ABI_EPOCH`] the side was built with; see its rules for when
49    /// to bump what.
50    pub abi_epoch: u32,
51    pub trait_object_size: usize,
52    pub audio_buffer_size: usize,
53    pub process_context_size: usize,
54    pub process_status_size: usize,
55    pub event_size: usize,
56    pub event_body_size: usize,
57    pub transport_size: usize,
58    pub widget_region_size: usize,
59    pub theme_size: usize,
60    pub plugin_layout_size: usize,
61    pub color_size: usize,
62    pub vec_u8_size: usize,
63    pub option_usize_size: usize,
64    pub audio_buffer_align: usize,
65    pub process_status_align: usize,
66    pub result_normal_disc: u8,
67    pub result_tail_disc: u8,
68    pub result_keepalive_disc: u8,
69    pub rustc_version_hash: u64,
70    /// Bit-width of the plugin's chosen sample type - `32` for `f32`,
71    /// `64` for `f64`. Without this field, a shell built against
72    /// `prelude` (f32) loading a logic dylib built against `prelude64`
73    /// would bind to a vtable whose `process()` slot expects
74    /// `AudioBuffer<f64>` - silent UB on the first audio block. The
75    /// width difference between the two `AudioBuffer<S>` instantiations
76    /// (and `dyn PluginLogic<S>`) is invisible at the dyn-trait
77    /// boundary, so a structural canary alone wouldn't catch it.
78    pub sample_precision: u8,
79}
80
81impl AbiCanary {
82    /// Build the canary for a specific sample precision `S`. The
83    /// shell calls this with its own `S`; the dylib's
84    /// `truce_abi_canary_v2` export does the same with its own (from the
85    /// prelude alias). The two are compared at load time.
86    #[must_use]
87    pub fn current<S: truce_params::sample::Sample>() -> Self {
88        // 8× sizeof gives us 32 for f32 / 64 for f64; the cast to u8
89        // can't overflow for any plausible sample type.
90        #[allow(clippy::cast_possible_truncation)]
91        let sample_precision = (size_of::<S>() * 8) as u8;
92        Self {
93            abi_epoch: ABI_EPOCH,
94            trait_object_size: size_of::<*const dyn PluginLogicCore<S>>() * 2,
95            audio_buffer_size: size_of::<AudioBuffer<S>>(),
96            process_context_size: size_of::<ProcessContext>(),
97            process_status_size: size_of::<ProcessStatus>(),
98            event_size: size_of::<Event>(),
99            event_body_size: size_of::<EventBody>(),
100            transport_size: size_of::<Transport>(),
101            widget_region_size: size_of::<WidgetRegion>(),
102            theme_size: size_of::<Theme>(),
103            plugin_layout_size: size_of::<GridLayout>(),
104            color_size: size_of::<Color>(),
105            vec_u8_size: size_of::<Vec<u8>>(),
106            option_usize_size: size_of::<Option<usize>>(),
107            audio_buffer_align: align_of::<AudioBuffer<S>>(),
108            process_status_align: align_of::<ProcessStatus>(),
109            result_normal_disc: discriminant_byte(&ProcessStatus::Normal),
110            result_tail_disc: discriminant_byte(&ProcessStatus::Tail(0)),
111            result_keepalive_disc: discriminant_byte(&ProcessStatus::KeepAlive),
112            rustc_version_hash: rustc_hash(),
113            sample_precision,
114        }
115    }
116
117    #[must_use]
118    pub fn matches(&self, other: &Self) -> bool {
119        self.field_diffs(other).is_empty()
120    }
121
122    #[must_use]
123    pub fn diff_report(&self, other: &Self) -> String {
124        let diffs = self.field_diffs(other);
125        if diffs.is_empty() {
126            "no differences".into()
127        } else {
128            format!("ABI mismatches:\n{}", diffs.join("\n"))
129        }
130    }
131
132    fn field_diffs(&self, other: &Self) -> Vec<String> {
133        let mut diffs = Vec::new();
134        macro_rules! check {
135            ($field:ident) => {
136                if self.$field != other.$field {
137                    diffs.push(format!(
138                        "  {}: shell={}, dylib={}",
139                        stringify!($field),
140                        self.$field,
141                        other.$field
142                    ));
143                }
144            };
145        }
146        // Single source of truth - adding a field to AbiCanary means
147        // adding one line below; `matches` and `diff_report` both
148        // reuse this list.
149        check!(abi_epoch);
150        check!(trait_object_size);
151        check!(audio_buffer_size);
152        check!(process_context_size);
153        check!(process_status_size);
154        check!(event_size);
155        check!(event_body_size);
156        check!(transport_size);
157        check!(widget_region_size);
158        check!(theme_size);
159        check!(plugin_layout_size);
160        check!(color_size);
161        check!(vec_u8_size);
162        check!(option_usize_size);
163        check!(audio_buffer_align);
164        check!(process_status_align);
165        check!(result_normal_disc);
166        check!(result_tail_disc);
167        check!(result_keepalive_disc);
168        check!(rustc_version_hash);
169        check!(sample_precision);
170        diffs
171    }
172}
173
174fn discriminant_byte<T>(value: &T) -> u8 {
175    // SAFETY: `value: &T` points to a valid `T`, and any `T` has at
176    // least its first byte readable (alignment + size > 0). The
177    // discriminant of a `#[repr(...)]`-tagged or default-repr enum
178    // lives at offset 0, so the first byte is exactly the value the
179    // canary wants to compare. For non-enum `T` the byte is whatever
180    // the layout puts there - fine, because the canary fields that
181    // call this (`result_*_disc`) only pass `ProcessStatus` variants
182    // and only compare the result against the matching dylib reading
183    // of the same call.
184    unsafe { *ptr::from_ref::<T>(value).cast::<u8>() }
185}
186
187fn rustc_hash() -> u64 {
188    env!("TRUCE_RUSTC_HASH").parse().unwrap_or(0)
189}
190
191// ---------------------------------------------------------------------------
192// Vtable probe
193// ---------------------------------------------------------------------------
194
195/// A plugin with known return values for vtable verification.
196///
197/// The shell creates this via `truce_vtable_probe()`, calls every
198/// method, and checks the results. If any method returns the wrong
199/// value, the vtable is reordered and the dylib is rejected.
200///
201/// `last_load_state` is the only mutable cell - `load_state` writes
202/// it, `save_state` reads it back. This lets `verify_probe`
203/// round-trip a sentinel through the load/save pair to confirm the
204/// `load_state` slot isn't swapped with another `&mut self` slot.
205#[derive(Default)]
206pub struct ProbePlugin {
207    last_load_state: RefCell<Vec<u8>>,
208}
209
210impl<S: Sample> PluginLogicCore<S> for ProbePlugin {
211    fn supports_in_place() -> bool
212    where
213        Self: Sized,
214    {
215        false
216    }
217
218    fn bus_layouts() -> Vec<truce_core::bus::BusLayout>
219    where
220        Self: Sized,
221    {
222        vec![truce_core::bus::BusLayout::stereo()]
223    }
224
225    fn reset(&mut self, _sr: f64, _bs: usize) {}
226
227    fn process(
228        &mut self,
229        _buffer: &mut AudioBuffer<S>,
230        _events: &EventList,
231        _context: &mut ProcessContext,
232    ) -> ProcessStatus {
233        ProcessStatus::Normal
234    }
235
236    fn save_state(&self) -> Vec<u8> {
237        // If `load_state` wasn't called, return the default sentinel;
238        // otherwise echo what was just loaded so verify can check the
239        // load/save vtable slots aren't crossed.
240        let cached = self.last_load_state.borrow();
241        if cached.is_empty() {
242            vec![0xCA, 0xFE]
243        } else {
244            cached.clone()
245        }
246    }
247    fn load_state(&mut self, data: &[u8]) -> Result<(), truce_core::state::StateLoadError> {
248        *self.last_load_state.borrow_mut() = data.to_vec();
249        Ok(())
250    }
251    fn state_changed(&mut self) {}
252    fn migrate_state(
253        _foreign: &truce_core::state::ForeignState,
254    ) -> Option<truce_core::state::MigratedState>
255    where
256        Self: Sized,
257    {
258        // Receiverless, so it has no vtable slot to probe.
259        None
260    }
261    fn latency(&self) -> u32 {
262        0xAAAA
263    }
264    fn tail(&self) -> u32 {
265        0xBBBB
266    }
267}
268
269/// Verify a probe plugin returns the expected values.
270///
271/// Coverage notes: methods exercised, in source-declaration order:
272/// `latency`, `tail`, `save_state` (default path), then `load_state` +
273/// `save_state` (echo path). 4 of `PluginLogicCore`'s 8 instance
274/// methods covered. The four not exercised (`reset`, `process`,
275/// `state_changed`, `editor`) would require constructing an
276/// `AudioBuffer` / opening a real window mock, heavyweight enough to
277/// outweigh the marginal vtable-reorder detection benefit.
278/// (Trait-object dispatch goes through a vtable whose slot order is
279/// rustc-internal and not stable; we don't depend on a particular
280/// layout. The goal here is just to call enough of the surface that
281/// any ABI-affecting reshuffle is likely to land on a method we *do*
282/// exercise.)
283///
284/// # Errors
285///
286/// Returns `Err(ProbeError)` on the first canary value that failed
287/// to round-trip. Each variant pins which trait method drifted so
288/// callers can pattern-match.
289#[cfg(feature = "shell")]
290pub fn verify_probe<S: Sample>(probe: &mut dyn PluginLogicCore<S>) -> Result<(), ProbeError> {
291    if probe.latency() != 0xAAAA {
292        return Err(ProbeError::Latency {
293            expected: 0xAAAA,
294            actual: probe.latency(),
295        });
296    }
297    if probe.tail() != 0xBBBB {
298        return Err(ProbeError::Tail {
299            expected: 0xBBBB,
300            actual: probe.tail(),
301        });
302    }
303    if probe.save_state() != vec![0xCA, 0xFE] {
304        return Err(ProbeError::SaveStateDefault);
305    }
306    // Round-trip a sentinel through load_state → save_state to confirm
307    // the load slot isn't swapped with another `&mut self` slot.
308    let sentinel = vec![0xDEu8, 0xAD, 0xBE, 0xEF];
309    probe
310        .load_state(&sentinel)
311        .map_err(ProbeError::LoadStateFailed)?;
312    if probe.save_state() != sentinel {
313        return Err(ProbeError::LoadSaveRoundTrip);
314    }
315    Ok(())
316}
317
318/// Why a vtable probe rejected a candidate dylib. Each variant
319/// names the trait method whose canary value drifted; the loader
320/// logs the `Display` form and refuses the load.
321#[cfg(feature = "shell")]
322#[derive(Debug)]
323pub enum ProbeError {
324    /// `PluginLogicCore::latency` didn't return the canary value.
325    Latency { expected: u32, actual: u32 },
326    /// `PluginLogicCore::tail` didn't return the canary value.
327    Tail { expected: u32, actual: u32 },
328    /// `PluginLogicCore::save_state` default path didn't return
329    /// the canary `[0xCA, 0xFE]`.
330    SaveStateDefault,
331    /// `PluginLogicCore::load_state` itself failed (returned `Err`)
332    /// for the canary sentinel.
333    LoadStateFailed(truce_core::state::StateLoadError),
334    /// `load_state` + `save_state` together didn't echo the
335    /// sentinel back - the two `&mut self` slots are crossed.
336    LoadSaveRoundTrip,
337}
338
339#[cfg(feature = "shell")]
340impl std::fmt::Display for ProbeError {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        match self {
343            Self::Latency { expected, actual } => {
344                write!(f, "latency: expected 0x{expected:X}, got 0x{actual:X}")
345            }
346            Self::Tail { expected, actual } => {
347                write!(f, "tail: expected 0x{expected:X}, got 0x{actual:X}")
348            }
349            Self::SaveStateDefault => f.write_str("save_state (default): expected [0xCA, 0xFE]"),
350            Self::LoadStateFailed(e) => write!(f, "load_state probe: {e}"),
351            Self::LoadSaveRoundTrip => f.write_str("load_state/save_state round-trip mismatch"),
352        }
353    }
354}
355
356#[cfg(feature = "shell")]
357impl std::error::Error for ProbeError {}
358
359#[cfg(test)]
360mod tests {
361    use super::{ABI_EPOCH, AbiCanary};
362
363    #[test]
364    fn same_build_matches_itself() {
365        let a = AbiCanary::current::<f32>();
366        let b = AbiCanary::current::<f32>();
367        assert!(a.matches(&b));
368    }
369
370    #[test]
371    fn epoch_mismatch_fails_and_names_the_field() {
372        // A layout change that lands in former padding leaves every
373        // size field identical - the epoch is the only tripwire.
374        let a = AbiCanary::current::<f32>();
375        let mut b = AbiCanary::current::<f32>();
376        b.abi_epoch = ABI_EPOCH - 1;
377        assert!(!a.matches(&b));
378        assert!(a.diff_report(&b).contains("abi_epoch"));
379    }
380
381    #[test]
382    fn precision_mismatch_fails() {
383        let a = AbiCanary::current::<f32>();
384        let b = AbiCanary::current::<f64>();
385        assert!(!a.matches(&b));
386    }
387}