Skip to main content

xabi_assert/
lib.rs

1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::Path;
5use std::process::Command;
6
7use xabi::{
8    XabiExportLayout, XabiLayout, XabiLayoutItem, XabiLayoutStability, XabiTypeLayout,
9    XabiVTableLayout,
10};
11
12/// Default directory used by [`assert_abi!`].
13pub const DEFAULT_SNAPSHOT_DIR: &str = "xabi/snapshots";
14
15/// Assert that a generated xabi module layout matches its committed snapshot.
16///
17/// The first argument is a module annotated with `#[xabi::module]`. The optional
18/// second argument overrides the snapshot directory, which defaults to
19/// `xabi/snapshots`.
20///
21/// ```no_run
22/// # mod exports {
23/// #     pub static XABI_LAYOUT: xabi::XabiLayout = xabi::XabiLayout {
24/// #         package: "demo",
25/// #         module: "demo::exports",
26/// #         collect: |_| {},
27/// #     };
28/// # }
29/// xabi_assert::assert_abi!(exports, "xabi/snapshots");
30/// ```
31#[macro_export]
32macro_rules! assert_abi {
33    ($module:path $(,)?) => {{
34        use $module as __xabi_assert_module;
35        $crate::assert_layout_in(
36            &__xabi_assert_module::XABI_LAYOUT,
37            env!("CARGO_MANIFEST_DIR"),
38            $crate::DEFAULT_SNAPSHOT_DIR,
39        );
40    }};
41    ($module:path, $snapshot_dir:expr $(,)?) => {{
42        use $module as __xabi_assert_module;
43        $crate::assert_layout_in(
44            &__xabi_assert_module::XABI_LAYOUT,
45            env!("CARGO_MANIFEST_DIR"),
46            $snapshot_dir,
47        );
48    }};
49}
50
51/// Assert that a layout matches a snapshot directory.
52///
53/// `manifest_dir` is the Cargo manifest directory used as the base for relative
54/// snapshot paths. Use [`assert_abi!`] in tests unless a caller needs custom
55/// path resolution.
56pub fn assert_layout_in(
57    layout: &XabiLayout,
58    manifest_dir: impl AsRef<Path>,
59    snapshot_dir: impl AsRef<Path>,
60) {
61    let manifest_dir = manifest_dir.as_ref();
62    let snapshot_dir = snapshot_dir.as_ref();
63    let target = target_triple();
64    let snapshot_path = manifest_dir
65        .join(snapshot_dir)
66        .join(format!("{target}.txt"));
67    let actual = render_layout(layout, &target);
68
69    if std::env::var_os("XABI_UPDATE").is_some() {
70        if let Some(parent) = snapshot_path.parent() {
71            fs::create_dir_all(parent).unwrap_or_else(|err| {
72                panic!("failed to create {}: {err}", parent.display());
73            });
74        }
75        fs::write(&snapshot_path, actual).unwrap_or_else(|err| {
76            panic!("failed to write {}: {err}", snapshot_path.display());
77        });
78        return;
79    }
80
81    let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|err| {
82        panic!(
83            "failed to read ABI snapshot {}: {err}\nrun `XABI_UPDATE=1 cargo test` to create it",
84            snapshot_path.display()
85        );
86    });
87    let expected = normalize_line_endings(&expected);
88    let actual = normalize_line_endings(&actual);
89    if expected == actual {
90        return;
91    }
92
93    panic!("{}", mismatch_message(&snapshot_path, &expected, &actual));
94}
95
96fn render_layout(layout: &XabiLayout, target: &str) -> String {
97    let mut items = Vec::new();
98    (layout.collect)(&mut items);
99    let snapshot = Snapshot::from_layout(layout.package, target, items);
100    snapshot.render()
101}
102
103fn normalize_line_endings(value: &str) -> String {
104    value.replace("\r\n", "\n")
105}
106
107fn target_triple() -> String {
108    if let Ok(target) = std::env::var("XABI_TARGET") {
109        return target;
110    }
111
112    let output = Command::new("rustc")
113        .arg("-vV")
114        .output()
115        .unwrap_or_else(|err| panic!("failed to run rustc -vV: {err}"));
116    if !output.status.success() {
117        panic!(
118            "rustc -vV failed: {}",
119            String::from_utf8_lossy(&output.stderr)
120        );
121    }
122    let stdout = String::from_utf8(output.stdout)
123        .unwrap_or_else(|err| panic!("rustc -vV output is not UTF-8: {err}"));
124    stdout
125        .lines()
126        .find_map(|line| line.strip_prefix("host: ").map(str::to_string))
127        .unwrap_or_else(|| "rustc -vV did not report host triple".to_string())
128}
129
130fn mismatch_message(path: &Path, expected: &str, actual: &str) -> String {
131    let expected_lines = expected.lines().collect::<Vec<_>>();
132    let actual_lines = actual.lines().collect::<Vec<_>>();
133    let index = (0..expected_lines.len().max(actual_lines.len()))
134        .find(|index| expected_lines.get(*index) != actual_lines.get(*index));
135
136    let Some(index) = index else {
137        return format!("ABI snapshot mismatch: {}", path.display());
138    };
139    let compatibility = match compare_compatibility(expected, actual) {
140        Ok(()) => "append-only compatible; update the snapshot if this ABI change is intentional"
141            .to_string(),
142        Err(err) => format!("breaking or unparsable ABI change: {err}"),
143    };
144    format!(
145        "ABI snapshot mismatch at line {}\nexpected: {}\nactual:   {}\ncompatibility: {}\nrun `XABI_UPDATE=1 cargo test` only after intentionally changing the ABI",
146        index + 1,
147        expected_lines.get(index).copied().unwrap_or("<missing>"),
148        actual_lines.get(index).copied().unwrap_or("<missing>"),
149        compatibility,
150    )
151}
152
153fn compare_compatibility(expected: &str, actual: &str) -> Result<(), String> {
154    let expected = Snapshot::parse(expected)?;
155    let actual = Snapshot::parse(actual)?;
156
157    if expected.format != actual.format {
158        return Err(format!(
159            "snapshot format changed from {} to {}",
160            expected.format, actual.format
161        ));
162    }
163    if expected.package != actual.package {
164        return Err(format!(
165            "package changed from {} to {}",
166            expected.package, actual.package
167        ));
168    }
169    if expected.target != actual.target {
170        return Err(format!(
171            "target changed from {} to {}",
172            expected.target, actual.target
173        ));
174    }
175
176    for (key, expected_export) in &expected.exports {
177        let actual_export = actual
178            .exports
179            .get(key)
180            .ok_or_else(|| format!("export {} named {} was removed", key.0, key.1))?;
181        if actual_export.contract_version != expected_export.contract_version {
182            return Err(format!(
183                "export {} named {} contract version changed from {} to {}",
184                key.0, key.1, expected_export.contract_version, actual_export.contract_version,
185            ));
186        }
187    }
188
189    for (name, expected_ty) in &expected.types {
190        let actual_ty = actual
191            .types
192            .get(name)
193            .ok_or_else(|| format!("type {name} was removed"))?;
194        if actual_ty.stability != expected_ty.stability {
195            return Err(format!(
196                "type {name} stability changed from {} to {}",
197                expected_ty.stability.as_str(),
198                actual_ty.stability.as_str(),
199            ));
200        }
201        if actual_ty.align != expected_ty.align {
202            return Err(format!(
203                "type {name} alignment changed from {} to {}",
204                expected_ty.align, actual_ty.align
205            ));
206        }
207        match expected_ty.stability {
208            XabiLayoutStability::Fixed => {
209                if actual_ty.size != expected_ty.size {
210                    return Err(format!(
211                        "fixed type {name} size changed from {} to {}",
212                        expected_ty.size, actual_ty.size
213                    ));
214                }
215            }
216            XabiLayoutStability::Prefix => {
217                if actual_ty.size < expected_ty.size {
218                    return Err(format!(
219                        "prefix type {name} shrank from {} to {}",
220                        expected_ty.size, actual_ty.size
221                    ));
222                }
223            }
224        }
225        let actual_fields = actual_ty.field_map();
226        for expected_field in &expected_ty.fields {
227            let field_name = &expected_field.name;
228            let actual_field = actual_ty
229                .field_by_name(&actual_fields, field_name)
230                .ok_or_else(|| format!("type {name} field {field_name} was removed"))?;
231            if actual_field.offset != expected_field.offset || actual_field.ty != expected_field.ty
232            {
233                return Err(format!(
234                    "type {name} field {field_name} changed from offset={} type={} to offset={} type={}",
235                    expected_field.offset, expected_field.ty, actual_field.offset, actual_field.ty,
236                ));
237            }
238        }
239        if expected_ty.stability == XabiLayoutStability::Fixed
240            && actual_ty.fields.len() != expected_ty.fields.len()
241        {
242            return Err(format!("fixed type {name} field set changed"));
243        }
244        if expected_ty.stability == XabiLayoutStability::Prefix {
245            let expected_fields = expected_ty.field_map();
246            for field in &actual_ty.fields {
247                if !expected_fields.contains_key(field.name.as_str())
248                    && field.offset < expected_ty.size
249                {
250                    return Err(format!(
251                        "type {name} appended field {} at offset {} before old size {}",
252                        field.name, field.offset, expected_ty.size
253                    ));
254                }
255            }
256        }
257    }
258
259    for (name, expected_vtable) in &expected.vtables {
260        let actual_vtable = actual
261            .vtables
262            .get(name)
263            .ok_or_else(|| format!("vtable {name} was removed"))?;
264        if actual_vtable.full_size < expected_vtable.full_size {
265            return Err(format!(
266                "vtable {name} shrank from {} to {}",
267                expected_vtable.full_size, actual_vtable.full_size
268            ));
269        }
270        if actual_vtable.min_size > expected_vtable.min_size {
271            return Err(format!(
272                "vtable {name} minimum prefix grew from {} to {}",
273                expected_vtable.min_size, actual_vtable.min_size
274            ));
275        }
276    }
277
278    Ok(())
279}
280
281#[derive(Default)]
282struct Snapshot {
283    format: String,
284    package: String,
285    target: String,
286    exports: BTreeMap<(String, String), ExportEntry>,
287    types: BTreeMap<String, TypeEntry>,
288    vtables: BTreeMap<String, VTableEntry>,
289}
290
291#[derive(Clone)]
292struct ExportEntry {
293    abi_id: String,
294    name: String,
295    contract_version: u32,
296}
297
298#[derive(Clone)]
299struct TypeEntry {
300    stability: XabiLayoutStability,
301    size: usize,
302    align: usize,
303    fields: Vec<FieldEntry>,
304}
305
306#[derive(Clone)]
307struct FieldEntry {
308    name: String,
309    offset: usize,
310    ty: String,
311}
312
313impl TypeEntry {
314    fn field_map(&self) -> BTreeMap<&str, &FieldEntry> {
315        self.fields
316            .iter()
317            .map(|field| (field.name.as_str(), field))
318            .collect()
319    }
320
321    fn field_by_name<'a>(
322        &'a self,
323        fields: &'a BTreeMap<&str, &FieldEntry>,
324        name: &str,
325    ) -> Option<&'a FieldEntry> {
326        fields.get(name).copied()
327    }
328}
329
330#[derive(Clone, Default)]
331struct VTableEntry {
332    full_size: usize,
333    min_size: usize,
334}
335
336enum SnapshotEntry {
337    Export((String, String)),
338    Type(String),
339    VTable(String),
340}
341
342impl Snapshot {
343    fn from_layout(package: &str, target: &str, items: Vec<XabiLayoutItem>) -> Self {
344        let mut snapshot = Self {
345            format: "xabi-layout-snapshot-v1".to_string(),
346            package: package.to_string(),
347            target: target.to_string(),
348            ..Self::default()
349        };
350
351        for item in items {
352            match item {
353                XabiLayoutItem::Export(export) => snapshot.insert_export(export),
354                XabiLayoutItem::Type(ty) => snapshot.insert_type(ty),
355                XabiLayoutItem::VTable(vtable) => snapshot.insert_vtable(vtable),
356            }
357        }
358
359        snapshot
360    }
361
362    fn insert_export(&mut self, export: XabiExportLayout) {
363        let entry = ExportEntry {
364            abi_id: export.abi_id.to_string(),
365            name: export.name.to_string(),
366            contract_version: export.contract_version,
367        };
368        let key = (entry.abi_id.clone(), entry.name.clone());
369        if let Some(existing) = self.exports.insert(key.clone(), entry.clone()) {
370            assert_export_equal(&key, &existing, &entry);
371        }
372    }
373
374    fn insert_type(&mut self, ty: XabiTypeLayout) {
375        let entry = TypeEntry {
376            stability: ty.stability,
377            size: ty.size,
378            align: ty.align,
379            fields: ty
380                .fields
381                .iter()
382                .map(|field| FieldEntry {
383                    name: field.name.to_string(),
384                    offset: field.offset,
385                    ty: field.ty.to_string(),
386                })
387                .collect(),
388        };
389        if let Some(existing) = self.types.insert(ty.name.to_string(), entry.clone()) {
390            assert_type_equal(ty.name, &existing, &entry);
391        }
392    }
393
394    fn insert_vtable(&mut self, vtable: XabiVTableLayout) {
395        let entry = VTableEntry {
396            full_size: vtable.full_size,
397            min_size: vtable.min_size,
398        };
399        if let Some(existing) = self.vtables.insert(vtable.name.to_string(), entry.clone()) {
400            assert_vtable_equal(vtable.name, &existing, &entry);
401        }
402    }
403
404    fn render(&self) -> String {
405        let mut out = String::new();
406        writeln!(out, "format={}", self.format).unwrap();
407        writeln!(out, "package={}", self.package).unwrap();
408        writeln!(out, "target={}", self.target).unwrap();
409        writeln!(out).unwrap();
410
411        for export in self.exports.values() {
412            writeln!(out, "export {} name={}", export.abi_id, export.name).unwrap();
413            writeln!(out, "  contract_version={}", export.contract_version).unwrap();
414            writeln!(out).unwrap();
415        }
416
417        for (name, ty) in &self.types {
418            writeln!(out, "type {name}").unwrap();
419            writeln!(out, "  stability={}", ty.stability.as_str()).unwrap();
420            writeln!(out, "  size={}", ty.size).unwrap();
421            writeln!(out, "  align={}", ty.align).unwrap();
422            for field in &ty.fields {
423                writeln!(
424                    out,
425                    "  field.{} offset={} type={}",
426                    field.name, field.offset, field.ty
427                )
428                .unwrap();
429            }
430            writeln!(out).unwrap();
431        }
432
433        for (name, vtable) in &self.vtables {
434            writeln!(out, "vtable {name}").unwrap();
435            writeln!(out, "  full_size={}", vtable.full_size).unwrap();
436            writeln!(out, "  min_size={}", vtable.min_size).unwrap();
437            writeln!(out).unwrap();
438        }
439
440        out
441    }
442
443    fn parse(input: &str) -> Result<Self, String> {
444        let mut snapshot = Snapshot::default();
445        let mut entry = None;
446
447        for line in input.lines() {
448            if line.is_empty() {
449                entry = None;
450                continue;
451            }
452            if let Some(format) = line.strip_prefix("format=") {
453                snapshot.format = format.to_string();
454                continue;
455            }
456            if let Some(package) = line.strip_prefix("package=") {
457                snapshot.package = package.to_string();
458                continue;
459            }
460            if let Some(target) = line.strip_prefix("target=") {
461                snapshot.target = target.to_string();
462                continue;
463            }
464            if let Some(rest) = line.strip_prefix("export ") {
465                let Some((abi_id, name)) = rest.split_once(" name=") else {
466                    return Err(format!("export line is missing name: {line}"));
467                };
468                snapshot.exports.insert(
469                    (abi_id.to_string(), name.to_string()),
470                    ExportEntry {
471                        abi_id: abi_id.to_string(),
472                        name: name.to_string(),
473                        contract_version: 0,
474                    },
475                );
476                entry = Some(SnapshotEntry::Export((
477                    abi_id.to_string(),
478                    name.to_string(),
479                )));
480                continue;
481            }
482            if let Some(name) = line.strip_prefix("type ") {
483                snapshot.types.insert(
484                    name.to_string(),
485                    TypeEntry {
486                        stability: XabiLayoutStability::Prefix,
487                        size: 0,
488                        align: 0,
489                        fields: Vec::new(),
490                    },
491                );
492                entry = Some(SnapshotEntry::Type(name.to_string()));
493                continue;
494            }
495            if let Some(name) = line.strip_prefix("vtable ") {
496                snapshot
497                    .vtables
498                    .insert(name.to_string(), VTableEntry::default());
499                entry = Some(SnapshotEntry::VTable(name.to_string()));
500                continue;
501            }
502
503            let Some(entry) = &entry else {
504                return Err(format!("line outside snapshot entry: {line}"));
505            };
506            let trimmed = line.trim_start();
507            match entry {
508                SnapshotEntry::Export(key) => {
509                    parse_export_line(&mut snapshot, key, trimmed)?;
510                }
511                SnapshotEntry::Type(name) => {
512                    parse_type_line(
513                        snapshot
514                            .types
515                            .get_mut(name)
516                            .expect("type entry exists while parsing"),
517                        trimmed,
518                    )?;
519                }
520                SnapshotEntry::VTable(name) => {
521                    parse_vtable_line(
522                        snapshot
523                            .vtables
524                            .get_mut(name)
525                            .expect("vtable entry exists while parsing"),
526                        trimmed,
527                    )?;
528                }
529            }
530        }
531
532        if snapshot.format.is_empty() {
533            return Err("snapshot format is missing".to_string());
534        }
535        if snapshot.package.is_empty() {
536            return Err("snapshot package is missing".to_string());
537        }
538        if snapshot.target.is_empty() {
539            return Err("snapshot target is missing".to_string());
540        }
541        Ok(snapshot)
542    }
543}
544
545fn parse_export_line(
546    snapshot: &mut Snapshot,
547    key: &(String, String),
548    line: &str,
549) -> Result<(), String> {
550    let entry = snapshot
551        .exports
552        .get_mut(key)
553        .ok_or_else(|| format!("export {} named {} entry is missing", key.0, key.1))?;
554    if let Some(version) = line.strip_prefix("contract_version=") {
555        entry.contract_version = parse_u32(version, "export contract version")?;
556        return Ok(());
557    }
558    Err(format!("unsupported export line: {line}"))
559}
560
561fn parse_type_line(layout: &mut TypeEntry, line: &str) -> Result<(), String> {
562    if let Some(value) = line.strip_prefix("stability=") {
563        layout.stability = parse_stability(value)?;
564        return Ok(());
565    }
566    if let Some(value) = line.strip_prefix("size=") {
567        layout.size = parse_usize(value, "type size")?;
568        return Ok(());
569    }
570    if let Some(value) = line.strip_prefix("align=") {
571        layout.align = parse_usize(value, "type align")?;
572        return Ok(());
573    }
574    let Some(rest) = line.strip_prefix("field.") else {
575        return Err(format!("unsupported type line: {line}"));
576    };
577    let Some((name, rest)) = rest.split_once(" offset=") else {
578        return Err(format!("field line is missing offset: {line}"));
579    };
580    let Some((offset, ty)) = rest.split_once(" type=") else {
581        return Err(format!("field line is missing type: {line}"));
582    };
583    layout.fields.push(FieldEntry {
584        name: name.to_string(),
585        offset: parse_usize(offset, "field offset")?,
586        ty: ty.to_string(),
587    });
588    Ok(())
589}
590
591fn parse_vtable_line(layout: &mut VTableEntry, line: &str) -> Result<(), String> {
592    if let Some(value) = line.strip_prefix("full_size=") {
593        layout.full_size = parse_usize(value, "vtable full_size")?;
594        return Ok(());
595    }
596    if let Some(value) = line.strip_prefix("min_size=") {
597        layout.min_size = parse_usize(value, "vtable min_size")?;
598        return Ok(());
599    }
600    Err(format!("unsupported vtable line: {line}"))
601}
602
603fn parse_stability(value: &str) -> Result<XabiLayoutStability, String> {
604    match value {
605        "fixed" => Ok(XabiLayoutStability::Fixed),
606        "prefix" => Ok(XabiLayoutStability::Prefix),
607        other => Err(format!("unsupported type stability: {other}")),
608    }
609}
610
611fn parse_usize(value: &str, context: &str) -> Result<usize, String> {
612    value
613        .parse()
614        .map_err(|err| format!("invalid {context} `{value}`: {err}"))
615}
616
617fn parse_u32(value: &str, context: &str) -> Result<u32, String> {
618    value
619        .parse()
620        .map_err(|err| format!("invalid {context} `{value}`: {err}"))
621}
622
623fn assert_export_equal(key: &(String, String), left: &ExportEntry, right: &ExportEntry) {
624    assert!(
625        left.abi_id == right.abi_id
626            && left.name == right.name
627            && left.contract_version == right.contract_version,
628        "conflicting xabi export layout for {} named {}",
629        key.0,
630        key.1,
631    );
632}
633
634fn assert_type_equal(name: &str, left: &TypeEntry, right: &TypeEntry) {
635    assert!(
636        left.stability == right.stability
637            && left.size == right.size
638            && left.align == right.align
639            && left.fields.len() == right.fields.len()
640            && left
641                .fields
642                .iter()
643                .zip(&right.fields)
644                .all(|(left, right)| left.name == right.name
645                    && left.offset == right.offset
646                    && left.ty == right.ty),
647        "conflicting xabi type layout for {name}",
648    );
649}
650
651fn assert_vtable_equal(name: &str, left: &VTableEntry, right: &VTableEntry) {
652    assert!(
653        left.full_size == right.full_size && left.min_size == right.min_size,
654        "conflicting xabi vtable layout for {name}",
655    );
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn append_only_prefix_change_is_compatible() {
664        let expected = "\
665format=xabi-layout-snapshot-v1
666package=demo
667target=test-target
668
669type demo::Wire
670  stability=prefix
671  size=16
672  align=8
673  field.size offset=0 type=usize
674
675";
676        let actual = "\
677format=xabi-layout-snapshot-v1
678package=demo
679target=test-target
680
681type demo::Wire
682  stability=prefix
683  size=24
684  align=8
685  field.size offset=0 type=usize
686  field.tail offset=16 type=u64
687
688";
689
690        compare_compatibility(expected, actual).expect("append-only change is compatible");
691    }
692}