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