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