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