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}