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
12pub const DEFAULT_SNAPSHOT_DIR: &str = "xabi/snapshots";
14
15#[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
51pub 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}