From bca3864af71be9560b053a18475a420fe9c0fd57 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Mon, 20 Jul 2026 17:54:11 -0700 Subject: [PATCH 1/8] cranelift-assembler-x64: add Intel XED as a fuzzer disassembler oracle The roundtrip fuzzer compares assembled instructions against Capstone, which cannot decode newer encodings such as APX. Add Intel XED as an optional second oracle: * Refactor the roundtrip core into `roundtrip_with(...)` so the Capstone path and a new XED path share one implementation (Capstone logic is unchanged, just extracted). * Implement `disassemble_xed` and `xed_matches` (normalizing whitespace, immediates, SIB scale-of-1, and operand-size/vector-length suffixes). * Gate XED behind a new off-by-default `fuzz-xed` feature, since it builds XED from source, and add a matching `roundtrip-xed` libFuzzer target. * Add the `smoke_xed` test, `#[ignore]`d while normalization (e.g. condition-code aliases) is still a work in progress. * Vet: exempt `xed-sys` and its `target-lexicon` requirement. --- Cargo.lock | 57 ++-- Cargo.toml | 1 + cranelift/assembler-x64/Cargo.toml | 6 + cranelift/assembler-x64/fuzz/Cargo.toml | 12 + .../fuzz/fuzz_targets/roundtrip-xed.rs | 16 ++ cranelift/assembler-x64/src/fuzz.rs | 258 +++++++++++++++++- supply-chain/config.toml | 8 + 7 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs diff --git a/Cargo.lock b/Cargo.lock index 72d86667c217..e890d367f22c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,6 +692,7 @@ dependencies = [ "arbtest", "capstone", "cranelift-assembler-x64-meta", + "xed-sys", ] [[package]] @@ -760,7 +761,7 @@ dependencies = [ "similar", "smallvec 1.15.1", "souper-ir", - "target-lexicon", + "target-lexicon 0.13.5", "wasmtime-internal-core", ] @@ -821,7 +822,7 @@ dependencies = [ "serde_derive", "similar", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "thiserror 2.0.17", "toml", "wasmtime-internal-unwinder", @@ -838,7 +839,7 @@ dependencies = [ "log", "similar", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -850,7 +851,7 @@ dependencies = [ "cranelift", "cranelift-native", "rand 0.10.1", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -967,7 +968,7 @@ dependencies = [ "log", "memmap2", "region", - "target-lexicon", + "target-lexicon 0.13.5", "wasmtime-internal-jit-icache-coherence", "wasmtime-internal-unwinder", "windows-sys 0.61.2", @@ -991,7 +992,7 @@ version = "0.135.0" dependencies = [ "cranelift-codegen", "libc", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -1007,7 +1008,7 @@ dependencies = [ "gimli 0.33.0", "log", "object 0.39.0", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -1017,7 +1018,7 @@ dependencies = [ "anyhow", "cranelift-codegen", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -1061,7 +1062,7 @@ dependencies = [ "rustc-hash", "serde", "similar", - "target-lexicon", + "target-lexicon 0.13.5", "thiserror 2.0.17", "toml", "walkdir", @@ -3709,6 +3710,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -4606,7 +4613,7 @@ dependencies = [ "serde_derive", "serde_json", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "tempfile", "tokio", "wasm-compose", @@ -4638,7 +4645,7 @@ version = "48.0.0" dependencies = [ "clap", "shuffling-allocator", - "target-lexicon", + "target-lexicon 0.13.5", "wasmtime", "wasmtime-cli-flags", "wasmtime-wasi", @@ -4712,7 +4719,7 @@ dependencies = [ "serde_json", "similar", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "tempfile", "termcolor", "test-programs-artifacts", @@ -4788,7 +4795,7 @@ dependencies = [ "serde_derive", "sha2", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "wasm-encoder 0.254.0", "wasmparser 0.254.0", "wasmprinter", @@ -4835,7 +4842,7 @@ dependencies = [ "quote", "rand 0.10.1", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "wasmparser 0.254.0", "wasmtime", "wasmtime-fuzzing", @@ -4860,7 +4867,7 @@ dependencies = [ "serde", "serde_json", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "tempfile", "tokio", "v8", @@ -4958,7 +4965,7 @@ dependencies = [ "object 0.39.0", "pulley-interpreter", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "thiserror 2.0.17", "wasmparser 0.254.0", "wasmtime-environ", @@ -4988,7 +4995,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "target-lexicon", + "target-lexicon 0.13.5", "wasmprinter", "wasmtime", "wasmtime-environ", @@ -5073,7 +5080,7 @@ dependencies = [ "gimli 0.33.0", "log", "object 0.39.0", - "target-lexicon", + "target-lexicon 0.13.5", "wasmparser 0.254.0", "wasmtime-environ", "wasmtime-internal-cranelift", @@ -5120,7 +5127,7 @@ dependencies = [ "rand 0.10.1", "serde", "serde_derive", - "target-lexicon", + "target-lexicon 0.13.5", "toml", "wasmparser 0.254.0", "wasmprinter", @@ -5448,7 +5455,7 @@ dependencies = [ "gimli 0.33.0", "regalloc2", "smallvec 1.15.1", - "target-lexicon", + "target-lexicon 0.13.5", "thiserror 2.0.17", "wasmparser 0.254.0", "wasmtime-environ", @@ -5886,6 +5893,16 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xed-sys" +version = "0.6.0+xed-2024.05.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078a73fb5587d5b3bb21262d2b38cf6a1e821ece8f7cbf9f82e61ca6f8a8df0e" +dependencies = [ + "cc", + "target-lexicon 0.12.16", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index fbb9c60ef25c..bdfba85d5c2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -410,6 +410,7 @@ clap = { version = "4.5.48", default-features = false, features = ["std", "deriv clap_complete = "4.5.58" hashbrown = { version = "0.17", default-features = false } capstone = { version = "0.14.0", default-features = false, features = ['full', 'arch_x86', 'arch_riscv', 'arch_arm64', 'arch_sysz'] } +xed-sys = { version = "0.6" } smallvec = { version = "1.15.1", features = ["union"] } tracing = { version = "0.1.41", default-features = false } bitflags = "2.9.4" diff --git a/cranelift/assembler-x64/Cargo.toml b/cranelift/assembler-x64/Cargo.toml index 35a7534b6210..2e87fde098ec 100644 --- a/cranelift/assembler-x64/Cargo.toml +++ b/cranelift/assembler-x64/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [dependencies] arbitrary = { workspace = true, features = ["derive"], optional = true } capstone = { workspace = true, optional = true } +xed-sys = { workspace = true, optional = true } [dev-dependencies] arbitrary = { workspace = true, features = ["derive"] } @@ -23,3 +24,8 @@ workspace = true [features] fuzz = ['dep:arbitrary', 'dep:capstone'] +# Adds Intel XED as a second, optional disassembler oracle for the roundtrip +# fuzzer. This is additive on top of `fuzz` (Capstone remains the default +# oracle); XED is only built when this feature is explicitly enabled. Note that +# building XED requires Python 3.9+ and a C compiler. +fuzz-xed = ['fuzz', 'dep:xed-sys'] diff --git a/cranelift/assembler-x64/fuzz/Cargo.toml b/cranelift/assembler-x64/fuzz/Cargo.toml index 384a47e3852a..c041559ec276 100644 --- a/cranelift/assembler-x64/fuzz/Cargo.toml +++ b/cranelift/assembler-x64/fuzz/Cargo.toml @@ -12,9 +12,21 @@ cargo-fuzz = true libfuzzer-sys = { workspace = true } cranelift-assembler-x64 = { path = "..", features = ['fuzz'] } +[features] +# Enable the Intel XED disassembler oracle for the `roundtrip-xed` target. +# This builds XED from source, so it requires a C compiler and Python. +fuzz-xed = ["cranelift-assembler-x64/fuzz-xed"] + [[bin]] name = "roundtrip" path = "fuzz_targets/roundtrip.rs" test = false doc = false bench = false + +[[bin]] +name = "roundtrip-xed" +path = "fuzz_targets/roundtrip-xed.rs" +test = false +doc = false +bench = false diff --git a/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs b/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs new file mode 100644 index 000000000000..7317304fdaf2 --- /dev/null +++ b/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs @@ -0,0 +1,16 @@ +#![no_main] + +use cranelift_assembler_x64::{Inst, fuzz}; +use libfuzzer_sys::fuzz_target; + +// This target drives the Intel XED disassembler oracle instead of Capstone. +// XED understands newer encodings (e.g. APX) that the bundled Capstone does +// not. Building XED from source is only done when the `fuzz-xed` feature is +// enabled; without it this target is a no-op so the default fuzz build does +// not require a C compiler and Python. +fuzz_target!(|inst: Inst| { + #[cfg(feature = "fuzz-xed")] + fuzz::roundtrip_xed(&inst); + #[cfg(not(feature = "fuzz-xed"))] + let _ = inst; +}); diff --git a/cranelift/assembler-x64/src/fuzz.rs b/cranelift/assembler-x64/src/fuzz.rs index adb0fd192100..b0e56c187099 100644 --- a/cranelift/assembler-x64/src/fuzz.rs +++ b/cranelift/assembler-x64/src/fuzz.rs @@ -18,6 +18,9 @@ use capstone::{Capstone, arch::BuildsCapstone, arch::BuildsCapstoneSyntax, arch: /// Take a random assembly instruction and check its encoding and /// pretty-printing against a known-good disassembler. /// +/// This uses Capstone as the disassembler oracle; see [`roundtrip_with`] for +/// the oracle-agnostic core. +/// /// # Panics /// /// This function panics to express failure as expected by the `arbitrary` @@ -31,6 +34,37 @@ pub fn roundtrip(inst: &Inst) { return; } + roundtrip_with(inst, "capstone", disassemble_capstone, capstone_matches); +} + +/// Like [`roundtrip`], but uses Intel XED as the disassembler oracle instead of +/// Capstone. +/// +/// XED understands newer encodings (e.g. APX) that the bundled Capstone does +/// not, so this is a useful second oracle. It is only available with the +/// `fuzz-xed` feature (which requires building XED from source). +/// +/// # Panics +/// +/// See [`roundtrip`]. +#[cfg(feature = "fuzz-xed")] +pub fn roundtrip_xed(inst: &Inst) { + roundtrip_with(inst, "xed", disassemble_xed, xed_matches); +} + +/// The oracle-agnostic core of [`roundtrip`]: assemble `inst`, disassemble the +/// resulting bytes with the provided `disassemble` oracle, and check that the +/// oracle's pretty-printed output matches the assembler's own `to_string`, +/// where "matches" is defined by the oracle-specific `matches` predicate +/// (`matches(expected_from_oracle, actual_from_assembler)`). +/// +/// The `oracle` name is only used to label diagnostic output on failure. +fn roundtrip_with( + inst: &Inst, + oracle: &str, + disassemble: impl Fn(&[u8], &Inst) -> String, + matches: impl Fn(&str, &str) -> bool, +) { // Check that we can actually assemble this instruction. let assembled = assemble(inst); let expected = disassemble(&assembled, inst); @@ -39,11 +73,11 @@ pub fn roundtrip(inst: &Inst) { // off the instruction offset first. let expected = expected.split_once(' ').unwrap().1; let actual = inst.to_string(); - if expected != actual && expected.trim() != fix_up(&actual) { + if !matches(expected, &actual) { println!("> {inst}"); println!(" debug: {inst:x?}"); println!(" assembled: {}", pretty_print_hexadecimal(&assembled)); - println!(" expected (capstone): {expected}"); + println!(" expected ({oracle}): {expected}"); println!(" actual (to_string): {actual}"); assert_eq!(expected, &actual); } @@ -60,6 +94,13 @@ fn features_mention(features: &Features, target: Feature) -> bool { } } +/// Comparison predicate for the Capstone oracle: exact match, or match after +/// applying Capstone-specific normalization ([`fix_up`]) to the assembler +/// output. +fn capstone_matches(expected: &str, actual: &str) -> bool { + expected == actual || expected.trim() == fix_up(actual) +} + /// Use this assembler to emit machine code into a byte buffer. /// /// This will skip any traps or label registrations, but this is fine for the @@ -132,8 +173,11 @@ impl CodeSink for TestCodeSink { } } +/// Disassemble a single instruction with Capstone, returning its AT&T-syntax +/// string. This is the default [`roundtrip`] oracle. +/// /// Building a new `Capstone` each time is suboptimal (TODO). -fn disassemble(assembled: &[u8], original: &Inst) -> String { +fn disassemble_capstone(assembled: &[u8], original: &Inst) -> String { let cs = Capstone::new() .x86() .mode(x86::ArchMode::Mode64) @@ -167,6 +211,79 @@ fn disassemble(assembled: &[u8], original: &Inst) -> String { inst.to_string() } +/// Disassemble a single instruction with Intel XED, returning a string in the +/// same shape as [`disassemble_capstone`] (a leading offset token, a space, +/// then the AT&T-syntax instruction) so that [`roundtrip_with`] can compare it +/// uniformly. +#[cfg(feature = "fuzz-xed")] +fn disassemble_xed(assembled: &[u8], original: &Inst) -> String { + use core::ffi::c_void; + use std::sync::Once; + use xed_sys::*; + + // XED requires a one-time global table initialization before any decode. + static INIT: Once = Once::new(); + // SAFETY: `xed_tables_init` is safe to call; `Once` guarantees it runs + // exactly once even across threads. + INIT.call_once(|| unsafe { xed_tables_init() }); + + // SAFETY: all of the following are standard XED decode/format calls + // operating on stack-allocated, properly initialized structures. + unsafe { + let mut xedd: xed_decoded_inst_t = core::mem::zeroed(); + xed_decoded_inst_zero(&mut xedd); + xed_decoded_inst_set_mode(&mut xedd, XED_MACHINE_MODE_LONG_64, XED_ADDRESS_WIDTH_64b); + + let error = xed_decode( + &mut xedd, + assembled.as_ptr(), + assembled.len() as core::ffi::c_uint, + ); + if error != XED_ERROR_NONE { + println!("> {original}"); + println!(" debug: {original:x?}"); + println!(" assembled: {}", pretty_print_hexadecimal(assembled)); + let name = core::ffi::CStr::from_ptr(xed_error_enum_t2str(error)); + panic!("xed failed to decode: {}", name.to_string_lossy()); + } + + // XED must consume exactly the bytes we emitted; a shorter length means + // trailing bytes were not part of the instruction. + let decoded_len = xed_decoded_inst_get_length(&xedd) as usize; + if decoded_len != assembled.len() { + println!("> {original}"); + println!(" debug: {original:x?}"); + println!(" assembled: {}", pretty_print_hexadecimal(assembled)); + assert_eq!( + decoded_len, + assembled.len(), + "xed did not consume all bytes" + ); + } + + // Format in AT&T syntax to match the assembler's own pretty-printing. + let mut buf = [0i8; 256]; + let ok = xed_format_context( + XED_SYNTAX_ATT, + &xedd, + buf.as_mut_ptr(), + buf.len() as core::ffi::c_int, + 0, + core::ptr::null_mut::(), + None, + ); + assert!(ok != 0, "xed failed to format instruction"); + + let disasm = core::ffi::CStr::from_ptr(buf.as_ptr()) + .to_string_lossy() + .into_owned(); + + // Prepend a fake offset token so the shape matches Capstone's + // `0x0: ` output that `roundtrip_with` expects. + format!("0: {disasm}") + } +} + fn pretty_print_hexadecimal(hex: &[u8]) -> String { use core::fmt::Write; let mut s = String::with_capacity(hex.len() * 2); @@ -286,6 +403,117 @@ fn fix_up(dis: &str) -> alloc::borrow::Cow<'_, str> { replace_signed_immediates(&dis) } +/// Comparison predicate for the Intel XED oracle. +/// +/// XED decodes the same instructions as the assembler but prints them with +/// slightly different conventions. The differences we reconcile here are: +/// +/// - XED omits the AT&T operand-size suffix on the mnemonic (e.g. `adc` +/// instead of `adcw`) when an operand already makes the width unambiguous. +/// - XED appends a vector-length marker (`x`/`y`/`z` for 128/256/512-bit) to +/// the mnemonic of some VEX/EVEX instructions with a memory operand (e.g. +/// `vpalignrx` instead of `vpalignr`). +/// - XED may use different internal whitespace (e.g. a double space after the +/// mnemonic). +/// +/// Rather than blindly stripping suffixes from the assembler mnemonic--which +/// would corrupt mnemonics that legitimately end in those letters, like `mul` +/// or `call`--we use XED's mnemonic as ground truth: a suffix is only dropped +/// if doing so makes the two mnemonics exactly equal. +#[cfg(feature = "fuzz-xed")] +fn xed_matches(expected: &str, actual: &str) -> bool { + let actual = remove_after_semicolon(actual); + + // Normalize runs of whitespace to a single space, and drop spaces that + // follow a comma, so cosmetic spacing differences (XED's double space after + // the mnemonic, and its lack of spaces inside memory operands like + // `(%rsi,%rdx,2)`) don't matter. Also drop an explicit SIB scale of 1, + // which XED prints (`(%rbp,%rsi,1)`) but the assembler omits. + fn normalize_ws(s: &str) -> String { + let collapsed = s.split_whitespace().collect::>().join(" "); + collapsed.replace(", ", ",").replace(",1)", ")") + } + let expected = canonicalize_immediates(&normalize_ws(expected)); + let actual = canonicalize_immediates(&normalize_ws(actual)); + if expected == actual { + return true; + } + + // Split "mnemonic operands" into the leading mnemonic and the remainder. + fn split_mnemonic(s: &str) -> (&str, &str) { + match s.split_once(' ') { + Some((m, rest)) => (m, rest), + None => (s, ""), + } + } + + let (exp_mnemonic, exp_ops) = split_mnemonic(&expected); + let (act_mnemonic, act_ops) = split_mnemonic(&actual); + + if exp_ops != act_ops { + return false; + } + + if act_mnemonic == exp_mnemonic { + return true; + } + + // The assembler mnemonic is the XED mnemonic plus a single trailing + // operand-size suffix (`adcw` vs `adc`). + if act_mnemonic.strip_suffix(['b', 'w', 'l', 'q']) == Some(exp_mnemonic) { + return true; + } + + // The XED mnemonic is the assembler mnemonic plus a trailing vector-length + // marker (`vpalignrx` vs `vpalignr`). + if exp_mnemonic.strip_suffix(['x', 'y', 'z']) == Some(act_mnemonic) { + return true; + } + + false +} + +/// Rewrite every `$`-prefixed immediate in a disassembly string into a single +/// canonical form so that decimal-vs-hex and signedness differences between the +/// assembler and XED don't cause spurious mismatches. +/// +/// The assembler prints small immediates in decimal (`$1`) and larger ones in +/// hex (`$0xb143`), while XED always prints hex (`$0x1`). We parse each +/// immediate's numeric value (handling an optional leading `-` and `0x`) and +/// re-emit it as `$0x{:x}` of its `u64` two's-complement value. +#[cfg(feature = "fuzz-xed")] +fn canonicalize_immediates(dis: &str) -> String { + let mut out = String::with_capacity(dis.len()); + let mut rest = dis; + while let Some(idx) = rest.find('$') { + out.push_str(&rest[..idx]); + // Everything after the '$'. + let after = &rest[idx + 1..]; + let (neg, num) = match after.strip_prefix('-') { + Some(n) => (true, n), + None => (false, after), + }; + let (radix, digits) = match num.strip_prefix("0x") { + Some(d) => (16, d), + None => (10, num), + }; + let n = digits.chars().take_while(|c| c.is_digit(radix)).count(); + if n == 0 { + // Not actually an immediate we can parse; keep the '$' literally. + out.push('$'); + rest = after; + continue; + } + let (value_str, tail) = digits.split_at(n); + let value = u64::from_str_radix(value_str, radix).unwrap_or(0); + let value = if neg { value.wrapping_neg() } else { value }; + out.push_str(&format!("$0x{value:x}")); + rest = tail; + } + out.push_str(rest); + out +} + /// Fuzz-specific registers. /// /// For the fuzzer, we do not need any fancy register types; see [`FuzzReg`]. @@ -430,4 +658,28 @@ mod test { roundtrip(&inst.into()); } } + + /// Same as [`smoke`], but exercises the Intel XED oracle. Only available + /// with the `fuzz-xed` feature. + /// + /// This is `#[ignore]`d for now: XED and the assembler agree on decoding, + /// but reconciling every one of XED's printing conventions is a work in + /// progress. The [`xed_matches`] predicate already handles operand-size + /// suffixes, vector-length markers, whitespace, immediate formatting, and + /// explicit SIB scales; the remaining known gap is condition-code mnemonic + /// aliases (e.g. `cmovnl` vs `cmovge`). Run explicitly with + /// `cargo test --features fuzz-xed -- --ignored smoke_xed`. + #[cfg(feature = "fuzz-xed")] + #[test] + #[ignore = "XED disassembly normalization is a work in progress"] + fn smoke_xed() { + let count = AtomicUsize::new(0); + arbtest(|u| { + let inst: Inst = u.arbitrary()?; + roundtrip_xed(&inst); + println!("#{}: {inst}", count.fetch_add(1, Ordering::SeqCst)); + Ok(()) + }) + .budget_ms(1_000); + } } diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 5dce8044f95d..d51f32d6f79f 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -475,6 +475,10 @@ criteria = "safe-to-deploy" version = "2.14.0" criteria = "safe-to-deploy" +[[exemptions.target-lexicon]] +version = "0.12.16" +criteria = "safe-to-deploy" + [[exemptions.tempfile]] version = "3.3.0" criteria = "safe-to-deploy" @@ -527,6 +531,10 @@ criteria = "safe-to-deploy" version = "0.4.0" criteria = "safe-to-deploy" +[[exemptions.xed-sys]] +version = "0.6.0+xed-2024.05.20" +criteria = "safe-to-deploy" + [[exemptions.zip]] version = "0.6.6" criteria = "safe-to-deploy" From e9aaea8fc8a261ea9cf32fb315220a51e4f778b2 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Wed, 22 Jul 2026 13:11:41 -0700 Subject: [PATCH 2/8] cranelift-assembler-x64: complete XED disassembly normalization The `smoke_xed` test was `#[ignore]`d because XED and the assembler agreed on decoding but printed instructions differently. Reconcile the remaining differences in `xed_matches` and enable the test by default. New cases handled: displacement/branch-target hex-vs-decimal, the reverse operand-size suffix, legacy prefixes, condition-code aliases, the AT&T/Intel convert and move-with-extension mnemonics, `movabs`, implicit operands (blend `%xmm0` mask, shift-by-one), the indirect-branch `*`, and the SSE/AVX compare pseudo-ops. All behind `fuzz-xed`, so the default and Capstone paths are unchanged. --- cranelift/assembler-x64/src/fuzz.rs | 493 ++++++++++++++++++++++++++-- 1 file changed, 471 insertions(+), 22 deletions(-) diff --git a/cranelift/assembler-x64/src/fuzz.rs b/cranelift/assembler-x64/src/fuzz.rs index b0e56c187099..ef5f2dc499ac 100644 --- a/cranelift/assembler-x64/src/fuzz.rs +++ b/cranelift/assembler-x64/src/fuzz.rs @@ -405,16 +405,31 @@ fn fix_up(dis: &str) -> alloc::borrow::Cow<'_, str> { /// Comparison predicate for the Intel XED oracle. /// -/// XED decodes the same instructions as the assembler but prints them with -/// slightly different conventions. The differences we reconcile here are: +/// XED decodes the same instructions as the assembler but prints them with a +/// number of different conventions. The differences we reconcile here are: /// -/// - XED omits the AT&T operand-size suffix on the mnemonic (e.g. `adc` -/// instead of `adcw`) when an operand already makes the width unambiguous. +/// - Cosmetic whitespace (XED's double space after the mnemonic, its lack of +/// spaces inside memory operands), an explicit SIB scale of 1, and the AT&T +/// indirect-branch marker `*` (as in `jmpq *%rax`). +/// - Numeric formatting: XED always prints hex while the assembler prints small +/// values in decimal. Normalized for `$` immediates, memory displacements, +/// and bare branch targets. +/// - XED omits the AT&T operand-size suffix on the mnemonic (`adc` vs `adcw`) +/// when an operand makes the width unambiguous, or conversely adds one the +/// assembler omits (`rcpssl` vs `rcpss`). /// - XED appends a vector-length marker (`x`/`y`/`z` for 128/256/512-bit) to -/// the mnemonic of some VEX/EVEX instructions with a memory operand (e.g. -/// `vpalignrx` instead of `vpalignr`). -/// - XED may use different internal whitespace (e.g. a double space after the -/// mnemonic). +/// some VEX/EVEX mnemonics (`vpalignrx` vs `vpalignr`). +/// - Legacy prefixes (`lock`, `rep*`, ...) printed as a leading token. +/// - Condition-code aliases (`cmovnb` vs `cmovae`). +/// - The AT&T/Intel spellings of the sign/zero-extend convert instructions +/// (`cltq` vs `cdqe`) and the move-with-extension instructions (`movzbl` vs +/// `movzxb`, `movslq` vs `movsxdl`). +/// - `movabs`(`q`) (assembler) vs plain `mov` (XED) for the imm64 move. +/// - Implicit operands the assembler prints but XED omits (the `%xmm0` mask of +/// the SSE4.1 variable blends, the `$1` count of shift/rotate-by-one). +/// - The SSE/AVX compare pseudo-ops, where the assembler bakes the predicate +/// into the mnemonic (`vcmpneqsd`) but XED uses a predicate immediate +/// (`vcmpsd $0x4, ...`). /// /// Rather than blindly stripping suffixes from the assembler mnemonic--which /// would corrupt mnemonics that legitimately end in those letters, like `mul` @@ -428,13 +443,18 @@ fn xed_matches(expected: &str, actual: &str) -> bool { // follow a comma, so cosmetic spacing differences (XED's double space after // the mnemonic, and its lack of spaces inside memory operands like // `(%rsi,%rdx,2)`) don't matter. Also drop an explicit SIB scale of 1, - // which XED prints (`(%rbp,%rsi,1)`) but the assembler omits. + // which XED prints (`(%rbp,%rsi,1)`) but the assembler omits. Finally, drop + // the AT&T indirect-branch marker `*` (as in `jmpq *%rax`), which the + // assembler prints but XED does not; `*` has no other use in this syntax. fn normalize_ws(s: &str) -> String { let collapsed = s.split_whitespace().collect::>().join(" "); - collapsed.replace(", ", ",").replace(",1)", ")") + collapsed + .replace(", ", ",") + .replace(",1)", ")") + .replace('*', "") } - let expected = canonicalize_immediates(&normalize_ws(expected)); - let actual = canonicalize_immediates(&normalize_ws(actual)); + let expected = canonicalize_displacements(&canonicalize_immediates(&normalize_ws(expected))); + let actual = canonicalize_displacements(&canonicalize_immediates(&normalize_ws(actual))); if expected == actual { return true; } @@ -447,8 +467,96 @@ fn xed_matches(expected: &str, actual: &str) -> bool { } } - let (exp_mnemonic, exp_ops) = split_mnemonic(&expected); - let (act_mnemonic, act_ops) = split_mnemonic(&actual); + // Strip any leading legacy instruction prefixes (`lock`, `rep*`, ...) that + // both disassemblers print as a separate leading token, so the mnemonic + // normalization below operates on the real operation mnemonic rather than + // the prefix. Both sides decode the same bytes, so their prefixes agree. + fn strip_legacy_prefixes(s: &str) -> &str { + let mut s = s; + while let Some((head, rest)) = s.split_once(' ') { + if matches!( + head, + "lock" | "rep" | "repe" | "repz" | "repne" | "repnz" | "data16" | "bnd" | "notrack" + ) { + s = rest; + } else { + break; + } + } + s + } + let expected = strip_legacy_prefixes(&expected); + let actual = strip_legacy_prefixes(&actual); + + let (exp_mnemonic, exp_ops) = split_mnemonic(expected); + let (act_mnemonic, act_ops) = split_mnemonic(actual); + + // XED makes the implicit shift/rotate-by-one count explicit (`sarl $0x1, X` + // vs the assembler's `sarl X`); drop a leading `$0x1,` for that family so + // the operand lists line up. + fn is_shift_rotate(m: &str) -> bool { + const ROOTS: [&str; 8] = ["sal", "sar", "shl", "shr", "rol", "ror", "rcl", "rcr"]; + ROOTS.contains(&m) + || m.strip_suffix(['b', 'w', 'l', 'q']) + .is_some_and(|r| ROOTS.contains(&r)) + } + fn strip_shift_one<'a>(m: &str, ops: &'a str) -> &'a str { + if is_shift_rotate(m) { + if let Some(rest) = ops.strip_prefix("$0x1,") { + return rest; + } + } + ops + } + + // The SSE4.1 variable-blend instructions (`blendvps`, `blendvpd`, + // `pblendvb`) take an implicit `%xmm0` mask, which the assembler prints as + // an explicit leading operand but XED omits. This only ever appears on the + // assembler (`actual`) side, so strip it there alone--stripping it from XED + // too would corrupt cases where `%xmm0` is also a real explicit operand. + fn strip_implicit_xmm0<'a>(m: &str, ops: &'a str) -> &'a str { + if m.starts_with("blendv") || m.starts_with("pblendv") { + if let Some(rest) = ops.strip_prefix("%xmm0,") { + return rest; + } + } + ops + } + let exp_ops = strip_shift_one(exp_mnemonic, exp_ops); + let act_ops = strip_implicit_xmm0(act_mnemonic, strip_shift_one(act_mnemonic, act_ops)); + + // Canonicalize any whole-operand bare number (e.g. a relative branch target + // like `jnp 5` vs `jnp 0x5`) into the shared hex form. Operands that are + // not pure numbers (registers, memory references) are left untouched. + fn canonicalize_operand_numbers(ops: &str) -> String { + ops.split(',') + .map(|op| canonicalize_one_number(op).unwrap_or_else(|| op.to_string())) + .collect::>() + .join(",") + } + let exp_ops = canonicalize_operand_numbers(exp_ops); + let act_ops = canonicalize_operand_numbers(act_ops); + + // SSE/AVX compare pseudo-ops: the assembler bakes the comparison predicate + // into the mnemonic (`vcmpneqsd`) while XED uses the generic mnemonic plus a + // leading predicate immediate (`vcmpsd $0x4, ...`). Convert whichever side + // is a pseudo-op into the generic `mnemonic + $imm` form and compare. + let exp_cmp = split_cmp_pseudo(exp_mnemonic).map(|(b, n)| (b, prepend_predicate(n, &exp_ops))); + let act_cmp = split_cmp_pseudo(act_mnemonic).map(|(b, n)| (b, prepend_predicate(n, &act_ops))); + if exp_cmp.is_some() || act_cmp.is_some() { + let (exp_base, exp_cops) = + exp_cmp.unwrap_or_else(|| (exp_mnemonic.to_string(), exp_ops.clone())); + let (act_base, act_cops) = + act_cmp.unwrap_or_else(|| (act_mnemonic.to_string(), act_ops.clone())); + if exp_cops == act_cops + && (exp_base == act_base + || act_base.strip_suffix(['b', 'w', 'l', 'q']).as_deref() == Some(&exp_base) + || exp_base.strip_suffix(['b', 'w', 'l', 'q']).as_deref() == Some(&act_base) + || exp_base.strip_suffix(['x', 'y', 'z']).as_deref() == Some(&act_base)) + { + return true; + } + } if exp_ops != act_ops { return false; @@ -464,15 +572,301 @@ fn xed_matches(expected: &str, actual: &str) -> bool { return true; } + // ...or vice versa: XED adds an operand-size suffix that the assembler + // omits (`rcpssl` vs `rcpss`) when a memory operand makes the width + // otherwise unstated. + if exp_mnemonic.strip_suffix(['b', 'w', 'l', 'q']) == Some(act_mnemonic) { + return true; + } + // The XED mnemonic is the assembler mnemonic plus a trailing vector-length // marker (`vpalignrx` vs `vpalignr`). if exp_mnemonic.strip_suffix(['x', 'y', 'z']) == Some(act_mnemonic) { return true; } + // XED and the assembler may spell the same condition code differently + // (`cmovnb` vs `cmovae`). Canonicalize both mnemonics' condition codes and + // compare; a match means they name the same conditional instruction. + if let (Some(exp_canon), Some(act_canon)) = ( + canonical_condition_mnemonic(exp_mnemonic), + canonical_condition_mnemonic(act_mnemonic), + ) { + if exp_canon == act_canon { + return true; + } + } + + // The sign/zero-extending conversion instructions have distinct AT&T and + // Intel mnemonics for the same opcode; the assembler prints the AT&T form + // (`cltq`) while XED prints the Intel form (`cdqe`) even in AT&T syntax. + if canonical_convert_mnemonic(exp_mnemonic) == canonical_convert_mnemonic(act_mnemonic) { + return true; + } + + // The move-with-extension instructions spell their operand sizes + // differently: the assembler encodes both source and destination sizes in + // the mnemonic (`movzbl` = zero-extend byte to long) while XED uses a + // single source-size suffix (`movzxb`), or omits it entirely (`movzx`) when + // a register source already states the width. Canonicalize both--taking the + // source size from the mnemonic or, failing that, the source operand--and + // compare. The operand lists are already known equal here. + if let (Some(exp_canon), Some(act_canon)) = ( + canonical_movext_mnemonic(exp_mnemonic, &exp_ops), + canonical_movext_mnemonic(act_mnemonic, &act_ops), + ) { + if exp_canon == act_canon { + return true; + } + } + + // The assembler names the imm64/moffs move `movabs`(`q`); XED prints plain + // `mov`. Strip the `abs` marker, then allow the usual operand-size suffix + // difference (`movq` vs `mov`). + let exp_dm = exp_mnemonic + .strip_prefix("movabs") + .map(|s| format!("mov{s}")); + let act_dm = act_mnemonic + .strip_prefix("movabs") + .map(|s| format!("mov{s}")); + if exp_dm.is_some() || act_dm.is_some() { + let e = exp_dm.as_deref().unwrap_or(exp_mnemonic); + let a = act_dm.as_deref().unwrap_or(act_mnemonic); + if e == a + || a.strip_suffix(['b', 'w', 'l', 'q']) == Some(e) + || e.strip_suffix(['b', 'w', 'l', 'q']) == Some(a) + { + return true; + } + } + false } +/// Map an SSE/AVX compare-predicate mnemonic fragment (as baked into the AT&T +/// pseudo-op mnemonics, e.g. `neq`) to its immediate predicate value. +#[cfg(feature = "fuzz-xed")] +fn cmp_predicate(name: &str) -> Option { + Some(match name { + "eq" => 0, + "lt" => 1, + "le" => 2, + "unord" => 3, + "neq" => 4, + "nlt" => 5, + "nle" => 6, + "ord" => 7, + "eq_uq" => 8, + "nge" => 9, + "ngt" => 10, + "false" => 11, + "neq_oq" => 12, + "ge" => 13, + "gt" => 14, + "true" => 15, + "eq_os" => 16, + "lt_oq" => 17, + "le_oq" => 18, + "unord_s" => 19, + "neq_us" => 20, + "nlt_uq" => 21, + "nle_uq" => 22, + "ord_s" => 23, + "eq_us" => 24, + "nge_uq" => 25, + "ngt_uq" => 26, + "false_os" => 27, + "neq_os" => 28, + "ge_oq" => 29, + "gt_oq" => 30, + "true_us" => 31, + _ => return None, + }) +} + +/// Recognize an SSE/AVX compare pseudo-op mnemonic (`cmpneqps`, `vcmpltsd`, +/// ...) and split it into its generic base mnemonic (`cmpps`, `vcmpsd`) and the +/// predicate immediate value. Returns `None` for any other mnemonic. +#[cfg(feature = "fuzz-xed")] +fn split_cmp_pseudo(m: &str) -> Option<(String, u8)> { + let (prefix, rest) = match m.strip_prefix('v') { + Some(r) => ("v", r), + None => ("", m), + }; + let rest = rest.strip_prefix("cmp")?; + for ty in ["ps", "pd", "ss", "sd"] { + if let Some(pred) = rest.strip_suffix(ty) { + if let Some(n) = cmp_predicate(pred) { + return Some((format!("{prefix}cmp{ty}"), n)); + } + } + } + None +} + +/// Prepend a predicate immediate (`$0xN`) to an operand list, matching the +/// generic-form operand ordering used by XED for the compare instructions. +#[cfg(feature = "fuzz-xed")] +fn prepend_predicate(n: u8, ops: &str) -> String { + if ops.is_empty() { + format!("$0x{n:x}") + } else { + format!("$0x{n:x},{ops}") + } +} + +/// The size letter implied by a source register operand (`%r11w` -> `w`), or +/// `None` for a memory operand (whose size cannot be read off the operand). +#[cfg(feature = "fuzz-xed")] +fn reg_source_size(op: &str) -> Option<&'static str> { + let reg = op.strip_prefix('%')?; + if reg.contains('(') { + return None; // memory operand + } + const B: &[&str] = &[ + "al", "bl", "cl", "dl", "sil", "dil", "spl", "bpl", "ah", "bh", "ch", "dh", + ]; + const W: &[&str] = &["ax", "bx", "cx", "dx", "si", "di", "sp", "bp"]; + const D: &[&str] = &["eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp"]; + if B.contains(®) || (reg.starts_with('r') && reg.ends_with('b')) { + Some("b") + } else if W.contains(®) || (reg.starts_with('r') && reg.ends_with('w')) { + Some("w") + } else if D.contains(®) || (reg.starts_with('r') && reg.ends_with('d')) { + Some("d") + } else { + Some("q") + } +} + +/// Canonicalize a move-with-zero/sign-extension mnemonic so the assembler's +/// two-size AT&T spelling and XED's spelling compare equal. +/// +/// The assembler writes `mov{z,s}` (e.g. `movzbl`, `movswq`). XED +/// writes `mov{z,s}x` (e.g. `movzxb`, `movsxd`) or, when a register source +/// already states the width, just `mov{z,s}x`. Both agree on the destination +/// via the register operand, so we reduce each to `mov{z,s}x` with the +/// source size taken from the mnemonic when present, else inferred from the +/// source operand `ops`; the 32-bit source is normalized (`l` and `d` both mean +/// doubleword). Returns `None` for any mnemonic that is not one of these. +#[cfg(feature = "fuzz-xed")] +fn canonical_movext_mnemonic(m: &str, ops: &str) -> Option { + fn norm_src(s: &str) -> &str { + match s { + "l" | "d" => "d", + other => other, + } + } + let source_op = ops.split(',').next().unwrap_or(ops); + for kind in ['z', 's'] { + let prefix = format!("mov{kind}"); + let Some(rest) = m.strip_prefix(&prefix) else { + continue; + }; + // XED form: `x`, optionally followed by a single source-size letter + // (`movzxb`, or bare `movzx`). `movsxd` may carry a redundant trailing + // operand-size suffix (`movsxdl` = movsxd + l). + if let Some(src) = rest.strip_prefix('x') { + let src = match src.strip_suffix(['b', 'w', 'l', 'q']) { + Some(s) if !s.is_empty() => s, + _ => src, + }; + return match src.len() { + 0 => reg_source_size(source_op).map(|s| format!("mov{kind}x{}", norm_src(s))), + 1 => Some(format!("mov{kind}x{}", norm_src(src))), + _ => None, + }; + } + // Assembler form: exactly a source-size then destination-size letter + // (`movzbl`). Anything else (e.g. `movsd`, `movsldup`) is not a + // move-with-extension mnemonic. + if rest.len() == 2 { + return Some(format!("mov{kind}x{}", norm_src(&rest[0..1]))); + } + return None; + } + None +} + +/// Canonicalize the sign/zero-extending "convert" instructions, whose AT&T and +/// Intel mnemonics differ for the same opcode (e.g. AT&T `cltq` vs Intel +/// `cdqe`). Returns the input unchanged if it is not one of these mnemonics. +#[cfg(feature = "fuzz-xed")] +fn canonical_convert_mnemonic(m: &str) -> &str { + match m { + "cbtw" | "cbw" => "cbw", + "cwtl" | "cwde" => "cwde", + "cltq" | "cdqe" => "cdqe", + "cwtd" | "cwd" => "cwd", + "cltd" | "cdq" => "cdq", + "cqto" | "cqo" => "cqo", + other => other, + } +} + +/// Canonicalize a conditional-instruction mnemonic so that different spellings +/// of the same condition code compare equal. +/// +/// x86 condition codes have multiple mnemonic aliases that denote the identical +/// flag test, e.g. `ae` (above-or-equal), `nb` (not-below), and `nc` +/// (not-carry) are the same condition. The assembler and XED may pick different +/// aliases, so for the conditional families (`cmov`, `set`, and the `j` +/// conditional jumps) we split off an optional trailing operand-size suffix, +/// map the condition code to a canonical representative, and return +/// `prefix + canonical-cc` (dropping the size suffix, which is already implied +/// by the operands that have been matched separately). +/// +/// Returns `None` if `m` is not a recognized conditional mnemonic, so callers +/// can fall through to other comparisons. +#[cfg(feature = "fuzz-xed")] +fn canonical_condition_mnemonic(m: &str) -> Option { + // Map every condition-code alias to a canonical representative. Aliases on + // the same line denote the same condition. + fn canonical_cc(cc: &str) -> Option<&'static str> { + Some(match cc { + "e" | "z" => "e", + "ne" | "nz" => "ne", + "b" | "c" | "nae" => "b", + "ae" | "nb" | "nc" => "ae", + "be" | "na" => "be", + "a" | "nbe" => "a", + "l" | "nge" => "l", + "ge" | "nl" => "ge", + "le" | "ng" => "le", + "g" | "nle" => "g", + "p" | "pe" => "p", + "np" | "po" => "np", + "o" => "o", + "no" => "no", + "s" => "s", + "ns" => "ns", + _ => return None, + }) + } + + // The conditional families we normalize. `j` must be tried last so that + // longer prefixes (`cmov`) are matched first. + for prefix in ["cmov", "set", "j"] { + let Some(rest) = m.strip_prefix(prefix) else { + continue; + }; + + // `rest` is the condition code, possibly followed by a single + // operand-size suffix (`cmovbq` = `cmov` + `b` + `q`). Only treat a + // trailing size character as a suffix when the remainder is itself a + // valid condition code; this avoids mis-parsing codes that genuinely + // end in a size-like letter (`nl`, `nb`). + let cc = match rest.strip_suffix(['b', 'w', 'l', 'q']) { + Some(stripped) if canonical_cc(stripped).is_some() => stripped, + _ => rest, + }; + + return canonical_cc(cc).map(|c| format!("{prefix}{c}")); + } + + None +} + /// Rewrite every `$`-prefixed immediate in a disassembly string into a single /// canonical form so that decimal-vs-hex and signedness differences between the /// assembler and XED don't cause spurious mismatches. @@ -514,6 +908,61 @@ fn canonicalize_immediates(dis: &str) -> String { out } +/// Parse a complete numeric token of the form `[-]?(0x)?` and re-emit +/// it in the canonical `0x{:x}` form used throughout XED normalization. Returns +/// `None` if `s` is not entirely a valid number. +#[cfg(feature = "fuzz-xed")] +fn canonicalize_one_number(s: &str) -> Option { + let (neg, num) = match s.strip_prefix('-') { + Some(n) => (true, n), + None => (false, s), + }; + let (radix, digits) = match num.strip_prefix("0x") { + Some(d) => (16, d), + None => (10, num), + }; + if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { + return None; + } + let value = u64::from_str_radix(digits, radix).ok()?; + let value = if neg { value.wrapping_neg() } else { value }; + Some(format!("0x{value:x}")) +} + +/// Rewrite every numeric memory displacement--the number immediately preceding +/// a `(` base/index group--into the same canonical `0x{:x}` form used for +/// immediates, so decimal-vs-hex differences in displacements don't cause +/// spurious mismatches (`-8(%rax)` vs `-0x8(%rax)`). +#[cfg(feature = "fuzz-xed")] +fn canonicalize_displacements(dis: &str) -> String { + let mut out = String::with_capacity(dis.len()); + for ch in dis.chars() { + if ch != '(' { + out.push(ch); + continue; + } + // Walk backwards over any trailing displacement token in `out`: + // hex digits, an optional `0x` prefix, and an optional leading `-`. + let b = out.as_bytes(); + let mut start = out.len(); + while start > 0 && b[start - 1].is_ascii_hexdigit() { + start -= 1; + } + if start >= 2 && &out[start - 2..start] == "0x" { + start -= 2; + } + if start > 0 && b[start - 1] == b'-' { + start -= 1; + } + if let Some(canon) = canonicalize_one_number(&out[start..]) { + out.truncate(start); + out.push_str(&canon); + } + out.push('('); + } + out +} + /// Fuzz-specific registers. /// /// For the fuzzer, we do not need any fancy register types; see [`FuzzReg`]. @@ -662,16 +1111,16 @@ mod test { /// Same as [`smoke`], but exercises the Intel XED oracle. Only available /// with the `fuzz-xed` feature. /// - /// This is `#[ignore]`d for now: XED and the assembler agree on decoding, - /// but reconciling every one of XED's printing conventions is a work in - /// progress. The [`xed_matches`] predicate already handles operand-size - /// suffixes, vector-length markers, whitespace, immediate formatting, and - /// explicit SIB scales; the remaining known gap is condition-code mnemonic - /// aliases (e.g. `cmovnl` vs `cmovge`). Run explicitly with - /// `cargo test --features fuzz-xed -- --ignored smoke_xed`. + /// XED decodes the same bytes as the assembler but pretty-prints them with + /// a number of different conventions; the [`xed_matches`] predicate + /// reconciles them (operand-size suffixes, vector-length markers, + /// whitespace, immediate/displacement/branch-target formatting, explicit + /// SIB scales, legacy prefixes, condition-code aliases, the AT&T/Intel + /// convert mnemonics, `movabs`, the move-with-extension mnemonics, implicit + /// operands, and the compare pseudo-ops). Run explicitly with + /// `cargo test --features fuzz-xed -- smoke_xed`. #[cfg(feature = "fuzz-xed")] #[test] - #[ignore = "XED disassembly normalization is a work in progress"] fn smoke_xed() { let count = AtomicUsize::new(0); arbtest(|u| { From 46c27c4196c9c89de0ba90119a16e2ddc2cfbd26 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Mon, 27 Jul 2026 16:52:05 -0700 Subject: [PATCH 3/8] cranelift-assembler-x64: run the XED oracle from the `misc` fuzzer Rather than adding a `roundtrip-xed` fuzz target, add an `assembler_roundtrip_xed` case to the existing `misc` fuzzer next to the Capstone-based `assembler_roundtrip`. That avoids spending a separate OSS-Fuzz target budget on what is really just a second oracle for the same instruction stream. The new case is appended to the end of the `run_fuzzers!` list so the input-byte discriminants of the existing fuzzers are unchanged, and it is listed unconditionally, with only its body compiled out, so those discriminants do not shift with feature selection either. XED is built from source and needs a C compiler and Python, so the new `fuzz-xed` feature is off by default. --- cranelift/assembler-x64/fuzz/Cargo.toml | 12 ----------- .../fuzz/fuzz_targets/roundtrip-xed.rs | 16 -------------- fuzz/Cargo.toml | 3 +++ fuzz/fuzz_targets/misc.rs | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 28 deletions(-) delete mode 100644 cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs diff --git a/cranelift/assembler-x64/fuzz/Cargo.toml b/cranelift/assembler-x64/fuzz/Cargo.toml index c041559ec276..384a47e3852a 100644 --- a/cranelift/assembler-x64/fuzz/Cargo.toml +++ b/cranelift/assembler-x64/fuzz/Cargo.toml @@ -12,21 +12,9 @@ cargo-fuzz = true libfuzzer-sys = { workspace = true } cranelift-assembler-x64 = { path = "..", features = ['fuzz'] } -[features] -# Enable the Intel XED disassembler oracle for the `roundtrip-xed` target. -# This builds XED from source, so it requires a C compiler and Python. -fuzz-xed = ["cranelift-assembler-x64/fuzz-xed"] - [[bin]] name = "roundtrip" path = "fuzz_targets/roundtrip.rs" test = false doc = false bench = false - -[[bin]] -name = "roundtrip-xed" -path = "fuzz_targets/roundtrip-xed.rs" -test = false -doc = false -bench = false diff --git a/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs b/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs deleted file mode 100644 index 7317304fdaf2..000000000000 --- a/cranelift/assembler-x64/fuzz/fuzz_targets/roundtrip-xed.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![no_main] - -use cranelift_assembler_x64::{Inst, fuzz}; -use libfuzzer_sys::fuzz_target; - -// This target drives the Intel XED disassembler oracle instead of Capstone. -// XED understands newer encodings (e.g. APX) that the bundled Capstone does -// not. Building XED from source is only done when the `fuzz-xed` feature is -// enabled; without it this target is a no-op so the default fuzz build does -// not require a C compiler and Python. -fuzz_target!(|inst: Inst| { - #[cfg(feature = "fuzz-xed")] - fuzz::roundtrip_xed(&inst); - #[cfg(not(feature = "fuzz-xed"))] - let _ = inst; -}); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 6dec73feaf7a..a5d6780b33f4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -52,6 +52,9 @@ default = ['fuzz-spec-interpreter', 'v8'] fuzz-spec-interpreter = ['wasmtime-fuzzing/fuzz-spec-interpreter'] v8 = ['wasmtime-fuzzing/v8'] chaos = ["cranelift-control/chaos"] +# Check the x64 assembler against Intel XED in the `misc` fuzzer. Off by default +# because XED is built from source, which requires a C compiler and Python. +fuzz-xed = ["cranelift-assembler-x64/fuzz-xed"] [[bin]] name = "compile" diff --git a/fuzz/fuzz_targets/misc.rs b/fuzz/fuzz_targets/misc.rs index 16f9838231e9..d1c46075182f 100644 --- a/fuzz/fuzz_targets/misc.rs +++ b/fuzz/fuzz_targets/misc.rs @@ -69,6 +69,7 @@ run_fuzzers! { api_calls dominator_tree gc_access + assembler_roundtrip_xed } fn pulley_roundtrip(u: Unstructured<'_>) -> Result<()> { @@ -83,6 +84,26 @@ fn assembler_roundtrip(u: Unstructured<'_>) -> Result<()> { Ok(()) } +/// Same as [`assembler_roundtrip`], but checks the assembler against Intel XED +/// rather than Capstone. XED understands newer encodings (e.g. APX) that the +/// bundled Capstone cannot decode. +/// +/// Building XED requires a C compiler and Python, so this is gated behind the +/// `fuzz-xed` feature and is a no-op without it. The fuzzer is always listed in +/// `run_fuzzers!` regardless so that the input-byte discriminants of the other +/// fuzzers stay stable. +fn assembler_roundtrip_xed(u: Unstructured<'_>) -> Result<()> { + #[cfg(feature = "fuzz-xed")] + { + use cranelift_assembler_x64::{Inst, fuzz}; + let inst: Inst = Arbitrary::arbitrary_take_rest(u)?; + fuzz::roundtrip_xed(&inst); + } + #[cfg(not(feature = "fuzz-xed"))] + let _ = u; + Ok(()) +} + fn memory_accesses(u: Unstructured<'_>) -> Result<()> { wasmtime_fuzzing::oracles::memory::check_memory_accesses(Arbitrary::arbitrary_take_rest(u)?); Ok(()) From c92c81e424aff1fd137f633832ddfe57a4feae53 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Mon, 27 Jul 2026 18:02:13 -0700 Subject: [PATCH 4/8] ci: run the XED assembler tests on CI Add `cargo test -p cranelift-assembler-x64 --features fuzz-xed` to the nightly test job so the XED oracle is actually exercised rather than merely compiled. That job is already triggered by changes to paths matching `fuzz`, which covers both the assembler's `fuzz` module and the `misc` fuzz target. XED is built from source, but the C compiler and Python that needs are already available on the runner, so no extra packages are required. --- .github/workflows/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 852d0b870ba1..3bf811e0870e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -717,6 +717,12 @@ jobs: env: RUSTFLAGS: "--cfg arc_try_new" + # Check the x64 assembler against the Intel XED disassembler, which knows + # about newer encodings that the bundled Capstone cannot decode. This is + # opt-in via `fuzz-xed` because XED is built from source; the C compiler and + # Python that requires are already present on this runner. + - run: cargo test -p cranelift-assembler-x64 --features fuzz-xed + # Smoke test the coverage script. - run: rustup target add wasm32-unknown-unknown - run: rustup component add llvm-tools From 61359c7c23c7a1184e72a47ce41446cf85eeccd1 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Mon, 27 Jul 2026 18:10:09 -0700 Subject: [PATCH 5/8] ci: build the `misc` fuzzer with the XED oracle enabled `cargo test -p cranelift-assembler-x64 --features fuzz-xed` exercises the XED comparison logic, but it never compiles `misc.rs`, so a change that broke the `assembler_roundtrip_xed` wiring would go unnoticed. Pass `--features fuzz-xed` when checking the top-level fuzz targets so that wiring is built too. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3bf811e0870e..c5147fe12519 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -741,7 +741,7 @@ jobs: - uses: ./.github/actions/apt-get-install with: packages: ocaml-nox ocamlbuild ocaml-findlib libzarith-ocaml-dev - - run: cargo fuzz check --dev + - run: cargo fuzz check --dev --features fuzz-xed - run: cargo fuzz check --dev --fuzz-dir ./cranelift/isle/fuzz - run: cargo fuzz check --dev --fuzz-dir ./crates/environ/fuzz --features component-model - run: cargo fuzz check --dev --fuzz-dir ./cranelift/assembler-x64/fuzz From fa2c94198246843e17ab31707c97e5c9010d5c93 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Wed, 29 Jul 2026 19:28:01 -0700 Subject: [PATCH 6/8] cranelift-assembler-x64: print XED syntax via `Formatter::alternate` The XED oracle previously compared XED's disassembly against the assembler's own syntax and reconciled the two afterwards, which took ~500 lines of string rewriting. Instead, teach the assembler to print the XED dialect directly: `format!("{inst}")` is unchanged, while `format!("{inst:#}")` renders what XED would. XED's spelling differs from ours in a few systematic ways, all derived at build time in `dsl::Inst::xed_mnemonics` rather than annotated per instruction: - it drops the AT&T operand-size suffix we carry (`addl` -> `add`); - it prefers a different condition-code alias for six of the sixteen conditions (`cmovae` -> `cmovnb`); - it appends a width marker when an operand is memory (`addsd` -> `addsdq`), which is why the mnemonic is chosen at runtime from the `r/m` operand. Operand rendering follows the same flag: memory operands drop the spaces after commas, always state the SIB scale, and use hexadecimal displacements. The `;; implicit:` and trap annotations are ours alone, so the alternate form omits them, as it does the fixed `%xmm0` blend mask. This leaves a single residual difference -- XED pads the mnemonic field with extra spaces -- so `xed_matches` is now a one-line comparison. Verified by fuzzing the whole instruction set: zero mnemonic and zero operand disagreements. --- cranelift/assembler-x64/meta/src/dsl.rs | 156 +++++ .../assembler-x64/meta/src/dsl/format.rs | 7 + .../assembler-x64/meta/src/generate/format.rs | 14 + .../assembler-x64/meta/src/generate/inst.rs | 59 +- .../meta/src/generate/operand.rs | 18 + cranelift/assembler-x64/src/custom.rs | 237 +++---- cranelift/assembler-x64/src/fuzz.rs | 598 +----------------- cranelift/assembler-x64/src/mem.rs | 49 +- 8 files changed, 448 insertions(+), 690 deletions(-) diff --git a/cranelift/assembler-x64/meta/src/dsl.rs b/cranelift/assembler-x64/meta/src/dsl.rs index 475aa7d8d237..69e7778d386c 100644 --- a/cranelift/assembler-x64/meta/src/dsl.rs +++ b/cranelift/assembler-x64/meta/src/dsl.rs @@ -113,6 +113,162 @@ impl Inst { }); self } + + /// The mnemonics XED prints for this encoding: `(register form, memory + /// form)`. XED picks a different spelling than we do in three systematic + /// ways, all handled here: + /// + /// 1. it drops the AT&T operand-size suffix we carry (`addl` -> `add`); + /// 2. it prefers a different condition-code alias (`cmovae` -> `cmovnb`); + /// 3. it appends a width marker when an operand is *memory*, sized by that + /// operand (`addsd` -> `addsdq`, `cmovnb` -> `cmovnbl`). + /// + /// The two forms differ only when (3) applies, which is why this returns a + /// pair: the choice is made at runtime from the `r/m` operand. + #[must_use] + pub fn xed_mnemonics(&self) -> (String, String) { + // `lock_addb` is printed `lock addb`; see `custom::mnemonic`. + let mut base = match self.mnemonic.strip_prefix("lock_") { + Some(rest) => format!("lock {rest}"), + None => self.mnemonic.clone(), + }; + + // (1) Drop our size suffix when it matches the width of some operand. + // Mnemonics XED spells differently outright keep their suffix (it is + // part of the name), as do condition-code mnemonics whose trailing + // letter is the condition itself (`setb`) rather than a size. + if !is_exception(&base) && !is_condition_family(&base) && !keeps_suffix(&base, &self.format) + { + let widths: Vec<_> = self + .format + .locations() + .map(|l| width_marker(l.bits())) + .collect(); + if base.len() > 2 && widths.iter().any(|w| base.ends_with(w)) { + base.truncate(base.len() - 1); + } + } + + // (2) XED's preferred spelling for the families that have aliases. + base = rename(&base); + + // (3) A width marker is appended when the operand is memory, sized by + // that operand. `lea` names an address rather than accessing it, so + // never takes one; `push`/`pop` always do, even for registers; and for + // mnemonics that already name their width the marker repeats it + // (`pextrb` -> `pextrbb`). + if matches!(base.as_str(), "push" | "pop") { + let bits = self.format.locations().next().map_or(64, Location::bits); + let m = format!("{base}{}", width_marker(bits)); + return (m.clone(), m); + } + let mem = match self.format.uses_memory() { + Some(_) if base == "lea" => base.clone(), + Some(_) if keeps_suffix(&base, &self.format) => { + // The mnemonic already names its width; XED repeats it using + // its own letter (`pextrd` is 32-bit, so `pextrdl`). + let named = match &base[base.len() - 1..] { + "b" => 8, + "w" => 16, + "d" => 32, + _ => 64, + }; + format!("{base}{}", width_marker(named)) + } + Some(loc) => format!("{base}{}", width_marker(loc.bits())), + None => base.clone(), + }; + (base, mem) + } +} + +/// XED's single-letter marker for an operand width. +fn width_marker(bits: u16) -> &'static str { + match bits { + 8 => "b", + 16 => "w", + 32 => "l", + 64 => "q", + 128 => "x", + 256 => "y", + 512 => "z", + b => unreachable!("no XED width marker for {b} bits"), + } +} + +/// Mnemonics XED spells differently outright. +const XED_RENAMES: &[(&str, &str)] = &[ + ("cbtw", "data16 cbw"), + ("cltd", "cdq"), + ("cltq", "cdqe"), + ("cqto", "cqo"), + ("cwtd", "data16 cwd"), + ("cwtl", "cwde"), + ("movabsq", "mov"), + ("movslq", "movsxd"), +]; + +fn is_exception(m: &str) -> bool { + XED_RENAMES.iter().any(|(o, _)| *o == m) +} + +/// The sixteen x64 condition codes, as spelled in our mnemonics. +const CONDITIONS: &[&str] = &[ + "a", "ae", "b", "be", "e", "g", "ge", "l", "le", "ne", "no", "np", "ns", "o", "p", "s", +]; + +/// Whether `m` is a `cmov`/`set`/`j` mnemonic carrying a valid condition code. +fn is_condition_family(m: &str) -> bool { + ["cmov", "set", "j"] + .iter() + .filter_map(|p| m.strip_prefix(p)) + .any(|cc| CONDITIONS.contains(&cc)) +} + +/// Mnemonics whose trailing size letter names the instruction (`pextrb` +/// extracts a *byte*) rather than decorating it in the AT&T style. +fn keeps_suffix(m: &str, format: &Format) -> bool { + const INTRINSIC: &str = + "pextr pinsr vpextr vpinsr vpbroadcast pmovsxd pmovzxd vpmovsxd vpmovzxd"; + INTRINSIC.split(' ').any(|p| m.starts_with(p)) + // `movq` is a GPR move (suffix) or an XMM move (name) by operand type. + || (matches!(m, "movq" | "vmovq") && format.locations().any(|l| l.bits() == 128)) +} + +/// Rewrite a suffix-stripped mnemonic into XED's preferred spelling. +fn rename(base: &str) -> String { + if let Some((_, x)) = XED_RENAMES.iter().find(|(o, _)| *o == base) { + return (*x).to_string(); + } + + // `movzbl`/`movswq`/... collapse to plain `movzx`/`movsx`; after the size + // suffix is stripped a single source-width letter remains. + for (prefix, xed) in [("movz", "movzx"), ("movs", "movsx")] { + if let Some(rest) = base.strip_prefix(prefix) { + if rest.len() == 1 && "bwlq".contains(rest) { + return xed.to_string(); + } + } + } + + // Condition-code families: XED prefers the negated spelling for six of the + // sixteen conditions. + for prefix in ["cmov", "set", "j"] { + if let Some(cc) = base.strip_prefix(prefix) { + let renamed = match cc { + "a" => "nbe", + "ae" => "nb", + "e" => "z", + "g" => "nle", + "ge" => "nl", + "ne" => "nz", + _ => return base.to_string(), + }; + return format!("{prefix}{renamed}"); + } + } + + base.to_string() } impl core::fmt::Display for Inst { diff --git a/cranelift/assembler-x64/meta/src/dsl/format.rs b/cranelift/assembler-x64/meta/src/dsl/format.rs index 3167bb1e41b5..ac976ffd7b64 100644 --- a/cranelift/assembler-x64/meta/src/dsl/format.rs +++ b/cranelift/assembler-x64/meta/src/dsl/format.rs @@ -377,6 +377,13 @@ impl Location { } } + /// Return `true` if this operand is always memory (i.e., not a `r/m` that + /// may hold a register). + #[must_use] + pub fn is_memory_only(&self) -> bool { + matches!(self.kind(), OperandKind::Mem(_)) + } + /// Return `true` if any of the operands accepts a register (i.e., not an /// immediate); return `false` otherwise. #[must_use] diff --git a/cranelift/assembler-x64/meta/src/generate/format.rs b/cranelift/assembler-x64/meta/src/generate/format.rs index a0664463bf7d..e1e4a8bb3bfb 100644 --- a/cranelift/assembler-x64/meta/src/generate/format.rs +++ b/cranelift/assembler-x64/meta/src/generate/format.rs @@ -65,6 +65,20 @@ impl dsl::Format { ordered_ops.join(", ") } + /// Like [`Self::generate_att_style_operands`], but omits the fixed `%xmm0` + /// mask operand, which XED leaves implicit. + #[must_use] + pub(crate) fn generate_xed_style_operands(&self) -> String { + let ordered_ops: Vec<_> = self + .operands + .iter() + .filter(|o| !o.implicit && o.location != dsl::Location::xmm0) + .rev() + .map(|o| format!("{{{}}}", o.location)) + .collect(); + ordered_ops.join(", ") + } + #[must_use] pub(crate) fn generate_implicit_operands(&self) -> String { let ops: Vec<_> = self diff --git a/cranelift/assembler-x64/meta/src/generate/inst.rs b/cranelift/assembler-x64/meta/src/generate/inst.rs index 6d5e8b8862fe..a2eb137356ef 100644 --- a/cranelift/assembler-x64/meta/src/generate/inst.rs +++ b/cranelift/assembler-x64/meta/src/generate/inst.rs @@ -60,6 +60,7 @@ impl dsl::Inst { self.generate_new_function(f); f.empty_line(); self.generate_mnemonic_function(f); + self.generate_xed_mnemonic_function(f); f.empty_line(); self.generate_encode_function(f); f.empty_line(); @@ -118,6 +119,38 @@ impl dsl::Inst { ); } + /// `fn xed_mnemonic(&self) -> Cow<'static, str> { ... }` + /// + /// This is the mnemonic Intel XED prints for this encoding; it differs from + /// [`Self::generate_mnemonic_function`] often enough that the fuzzer's XED + /// oracle compares against it rather than reconciling the two after the + /// fact. See [`dsl::Inst::xed_mnemonics`]. + pub fn generate_xed_mnemonic_function(&self, f: &mut Formatter) { + let (reg, mem) = self.xed_mnemonics(); + fmtln!(f, "#[must_use]"); + fmtln!(f, "#[inline]"); + f.add_block( + &format!("pub fn xed_mnemonic(&self) -> alloc::borrow::Cow<'static, str>"), + |f| { + let borrow = |m| format!("alloc::borrow::Cow::Borrowed(\"{m}\")"); + let loc = self.format.uses_memory(); + match loc { + // The marker is only appended when the `r/m` operand holds + // memory; a memory-only operand always does. + Some(loc) if reg != mem && !loc.is_memory_only() => { + fmtln!(f, "if self.{loc}.is_memory() {{"); + f.indent(|f| fmtln!(f, "{}", borrow(&mem))); + fmtln!(f, "}} else {{"); + f.indent(|f| fmtln!(f, "{}", borrow(®))); + fmtln!(f, "}}"); + } + Some(_) if reg != mem => fmtln!(f, "{}", borrow(&mem)), + _ => fmtln!(f, "{}", borrow(®)), + } + }, + ); + } + /// `fn encode(&self, ...) { ... }` fn generate_encode_function(&self, f: &mut Formatter) { use dsl::Customization::*; @@ -275,7 +308,7 @@ impl dsl::Inst { return; } - fmtln!(f, "let name = self.mnemonic();"); + fmtln!(f, "let name = if f.alternate() {{ self.xed_mnemonic() }} else {{ self.mnemonic() }};"); if self.format.operands.is_empty() { fmtln!(f, "f.write_str(&name)"); return; @@ -283,9 +316,16 @@ impl dsl::Inst { for op in self.format.operands.iter() { let location = op.location; let to_string = location.generate_to_string(op.extension); - fmtln!(f, "let {location} = {to_string};"); + match location.generate_to_string_xed(op.extension) { + Some(xed) => fmtln!( + f, + "let {location} = if f.alternate() {{ {xed} }} else {{ {to_string} }};" + ), + None => fmtln!(f, "let {location} = {to_string};"), + } } let ordered_ops = self.format.generate_att_style_operands(); + let xed_ops = self.format.generate_xed_style_operands(); let mut implicit_ops = self.format.generate_implicit_operands(); if self.has_trap { fmtln!(f, "let trap = self.trap;"); @@ -295,7 +335,20 @@ impl dsl::Inst { implicit_ops.push_str(", {trap}"); } } - fmtln!(f, "write!(f, \"{{name}} {ordered_ops}{implicit_ops}\")"); + if implicit_ops.is_empty() && xed_ops == ordered_ops { + fmtln!(f, "write!(f, \"{{name}} {ordered_ops}\")"); + } else { + // The implicit-operand and trap annotations are + // ours; XED has no equivalent, and it leaves the + // fixed `%xmm0` mask implicit too. + fmtln!(f, "if f.alternate() {{"); + f.indent(|f| fmtln!(f, "write!(f, \"{{name}} {xed_ops}\")")); + fmtln!(f, "}} else {{"); + f.indent(|f| { + fmtln!(f, "write!(f, \"{{name}} {ordered_ops}{implicit_ops}\")") + }); + fmtln!(f, "}}"); + } }, ); }, diff --git a/cranelift/assembler-x64/meta/src/generate/operand.rs b/cranelift/assembler-x64/meta/src/generate/operand.rs index a2ed80a10153..aaa611b191fe 100644 --- a/cranelift/assembler-x64/meta/src/generate/operand.rs +++ b/cranelift/assembler-x64/meta/src/generate/operand.rs @@ -71,6 +71,24 @@ impl dsl::Location { } } + /// Like [`Self::generate_to_string`], but renders in XED's dialect; + /// `None` when the operand prints identically in both. + #[must_use] + pub fn generate_to_string_xed(&self, _extension: dsl::Extension) -> Option { + use dsl::Location::*; + match self { + rm8 | rm16 | rm32 | rm64 => { + let size = self.generate_size().expect("r/m operands have a size"); + Some(format!("self.{self}.to_string_xed({size})")) + } + xmm_m8 | xmm_m16 | xmm_m32 | xmm_m64 | xmm_m128 => { + Some(format!("self.{self}.to_string_xed()")) + } + m8 | m16 | m32 | m64 | m128 => Some(format!("alloc::format!(\"{{:#}}\", self.{self})")), + _ => None, + } + } + /// `Size::` #[must_use] fn generate_size(&self) -> Option<&str> { diff --git a/cranelift/assembler-x64/src/custom.rs b/cranelift/assembler-x64/src/custom.rs index 25c63e239cf4..2e7116e4bdd2 100644 --- a/cranelift/assembler-x64/src/custom.rs +++ b/cranelift/assembler-x64/src/custom.rs @@ -190,7 +190,8 @@ pub mod mnemonic { pub mod display { use crate::inst; - use crate::{Amode, Gpr, GprMem, Registers, Size}; + use crate::{Amode, AsReg, Gpr, GprMem, Registers, Size, XmmMem}; + use alloc::string::String; use alloc::string::ToString; use core::fmt; @@ -201,6 +202,10 @@ pub mod display { pub fn callq_m(f: &mut fmt::Formatter, inst: &inst::callq_m) -> fmt::Result { let inst::callq_m { rm64 } = inst; + // XED writes the target plainly, without the indirect `*`. + if f.alternate() { + return write!(f, "callq {}", rm64.to_string_xed(Size::Quadword)); + } let op = rm64.to_string(Size::Quadword); write!(f, "callq *{op}") } @@ -221,52 +226,54 @@ pub mod display { } } - pub fn cmpss_a(f: &mut fmt::Formatter, inst: &inst::cmpss_a) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm_m32 = inst.xmm_m32.to_string(); - let pred = inst.imm8.value(); - if pred > 7 { - let imm8 = inst.imm8.to_string(); - write!(f, "cmpss {imm8}, {xmm_m32}, {xmm1}") + /// Render a possibly-memory XMM operand in the dialect `f` asks for. + fn xmm_mem(f: &fmt::Formatter, op: &XmmMem) -> String { + if f.alternate() { + op.to_string_xed() } else { - write!(f, "cmp{}ss {xmm_m32}, {xmm1}", pred_as_str(pred)) + op.to_string() } } - pub fn cmpsd_a(f: &mut fmt::Formatter, inst: &inst::cmpsd_a) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm_m64 = inst.xmm_m64.to_string(); - let pred = inst.imm8.value(); - if pred > 7 { - let imm8 = inst.imm8.to_string(); - write!(f, "cmpsd {imm8}, {xmm_m64}, {xmm1}") + /// XED appends a width marker to the generic `cmp*` form when the operand + /// is memory; see `dsl::Inst::xed_mnemonics`. + fn marker( + f: &fmt::Formatter, + op: &XmmMem, + marker: &'static str, + ) -> &'static str { + if f.alternate() && op.is_memory() { + marker } else { - write!(f, "cmp{}sd {xmm_m64}, {xmm1}", pred_as_str(pred)) + "" } } - pub fn cmpps_a(f: &mut fmt::Formatter, inst: &inst::cmpps_a) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm_m128 = inst.xmm_m128.to_string(); - let pred = inst.imm8.value(); - if pred > 7 { - let imm8 = inst.imm8.to_string(); - write!(f, "cmpps {imm8}, {xmm_m128}, {xmm1}") - } else { - write!(f, "cmp{}ps {xmm_m128}, {xmm1}", pred_as_str(pred)) - } + /// The compare pseudo-ops: we name the predicate in the mnemonic + /// (`cmpeqss`), while XED always writes the generic form with an explicit + /// predicate immediate. + macro_rules! cmp { + ($($name:ident($sfx:tt, $op:ident, $mark:tt);)*) => ($( + pub fn $name(f: &mut fmt::Formatter, inst: &inst::$name) -> fmt::Result { + let xmm1 = inst.xmm1.to_string(); + let op = xmm_mem(f, &inst.$op); + let pred = inst.imm8.value(); + if f.alternate() || pred > 7 { + let m = marker(f, &inst.$op, $mark); + let imm8 = inst.imm8.to_string(); + write!(f, concat!("cmp", $sfx, "{} {}, {}, {}"), m, imm8, op, xmm1) + } else { + write!(f, concat!("cmp{}", $sfx, " {}, {}"), pred_as_str(pred), op, xmm1) + } + } + )*) } - pub fn cmppd_a(f: &mut fmt::Formatter, inst: &inst::cmppd_a) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm_m128 = inst.xmm_m128.to_string(); - let pred = inst.imm8.value(); - if pred > 7 { - let imm8 = inst.imm8.to_string(); - write!(f, "cmppd {imm8}, {xmm_m128}, {xmm1}") - } else { - write!(f, "cmp{}pd {xmm_m128}, {xmm1}", pred_as_str(pred)) - } + cmp! { + cmpss_a("ss", xmm_m32, "l"); + cmpsd_a("sd", xmm_m64, "q"); + cmpps_a("ps", xmm_m128, "x"); + cmppd_a("pd", xmm_m128, "x"); } /// Return the predicate string used for the immediate of a `vcmp*` @@ -309,72 +316,31 @@ pub mod display { } } - pub fn vcmpss_b(f: &mut fmt::Formatter, inst: &inst::vcmpss_b) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm2 = inst.xmm2.to_string(); - let xmm_m32 = inst.xmm_m32.to_string(); - let pred = inst.imm8.value(); - if pred > 0x1f { - let imm8 = inst.imm8.to_string(); - write!(f, "vcmpss {imm8}, {xmm_m32}, {xmm2}, {xmm1}") - } else { - write!( - f, - "vcmp{}ss {xmm_m32}, {xmm2}, {xmm1}", - vex_pred_as_str(pred) - ) - } - } - - pub fn vcmpsd_b(f: &mut fmt::Formatter, inst: &inst::vcmpsd_b) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm2 = inst.xmm2.to_string(); - let xmm_m64 = inst.xmm_m64.to_string(); - let pred = inst.imm8.value(); - if pred > 0x1f { - let imm8 = inst.imm8.to_string(); - write!(f, "vcmpsd {imm8}, {xmm_m64}, {xmm2}, {xmm1}") - } else { - write!( - f, - "vcmp{}sd {xmm_m64}, {xmm2}, {xmm1}", - vex_pred_as_str(pred) - ) - } + /// The VEX compare pseudo-ops; see the `cmp!` macro above. + macro_rules! vcmp { + ($($name:ident($sfx:tt, $op:ident, $mark:tt);)*) => ($( + pub fn $name(f: &mut fmt::Formatter, inst: &inst::$name) -> fmt::Result { + let xmm1 = inst.xmm1.to_string(); + let xmm2 = inst.xmm2.to_string(); + let op = xmm_mem(f, &inst.$op); + let pred = inst.imm8.value(); + if f.alternate() || pred > 0x1f { + let m = marker(f, &inst.$op, $mark); + let imm8 = inst.imm8.to_string(); + write!(f, concat!("vcmp", $sfx, "{} {}, {}, {}, {}"), m, imm8, op, xmm2, xmm1) + } else { + let p = vex_pred_as_str(pred); + write!(f, concat!("vcmp{}", $sfx, " {}, {}, {}"), p, op, xmm2, xmm1) + } + } + )*) } - pub fn vcmpps_b(f: &mut fmt::Formatter, inst: &inst::vcmpps_b) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm2 = inst.xmm2.to_string(); - let xmm_m128 = inst.xmm_m128.to_string(); - let pred = inst.imm8.value(); - if pred > 0x1f { - let imm8 = inst.imm8.to_string(); - write!(f, "vcmpps {imm8}, {xmm_m128}, {xmm2}, {xmm1}") - } else { - write!( - f, - "vcmp{}ps {xmm_m128}, {xmm2}, {xmm1}", - vex_pred_as_str(pred) - ) - } - } - - pub fn vcmppd_b(f: &mut fmt::Formatter, inst: &inst::vcmppd_b) -> fmt::Result { - let xmm1 = inst.xmm1.to_string(); - let xmm2 = inst.xmm2.to_string(); - let xmm_m128 = inst.xmm_m128.to_string(); - let pred = inst.imm8.value(); - if pred > 0x1f { - let imm8 = inst.imm8.to_string(); - write!(f, "vcmppd {imm8}, {xmm_m128}, {xmm2}, {xmm1}") - } else { - write!( - f, - "vcmp{}pd {xmm_m128}, {xmm2}, {xmm1}", - vex_pred_as_str(pred) - ) - } + vcmp! { + vcmpss_b("ss", xmm_m32, "l"); + vcmpsd_b("sd", xmm_m64, "q"); + vcmpps_b("ps", xmm_m128, "x"); + vcmppd_b("pd", xmm_m128, "x"); } pub fn nop_1b(f: &mut fmt::Formatter, _: &inst::nop_1b) -> fmt::Result { @@ -382,7 +348,12 @@ pub mod display { } pub fn nop_2b(f: &mut fmt::Formatter, _: &inst::nop_2b) -> fmt::Result { - write!(f, "nop") + // XED spells the operand-size prefix out for the two-byte form. + if f.alternate() { + write!(f, "data16 nop") + } else { + write!(f, "nop") + } } pub fn nop_3b(f: &mut fmt::Formatter, _: &inst::nop_3b) -> fmt::Result { @@ -394,11 +365,19 @@ pub mod display { } pub fn nop_5b(f: &mut fmt::Formatter, _: &inst::nop_5b) -> fmt::Result { - write!(f, "nopl (%rax, %rax)") + if f.alternate() { + write!(f, "nopl (%rax,%rax,1)") + } else { + write!(f, "nopl (%rax, %rax)") + } } pub fn nop_6b(f: &mut fmt::Formatter, _: &inst::nop_6b) -> fmt::Result { - write!(f, "nopw (%rax, %rax)") + if f.alternate() { + write!(f, "nopw (%rax,%rax,1)") + } else { + write!(f, "nopw (%rax, %rax)") + } } pub fn nop_7b(f: &mut fmt::Formatter, _: &inst::nop_7b) -> fmt::Result { @@ -406,11 +385,19 @@ pub mod display { } pub fn nop_8b(f: &mut fmt::Formatter, _: &inst::nop_8b) -> fmt::Result { - write!(f, "nopl (%rax, %rax)") + if f.alternate() { + write!(f, "nopl (%rax,%rax,1)") + } else { + write!(f, "nopl (%rax, %rax)") + } } pub fn nop_9b(f: &mut fmt::Formatter, _: &inst::nop_9b) -> fmt::Result { - write!(f, "nopw (%rax, %rax)") + if f.alternate() { + write!(f, "nopw (%rax,%rax,1)") + } else { + write!(f, "nopw (%rax, %rax)") + } } pub fn xchgb_rm( @@ -453,7 +440,11 @@ pub mod display { size: Size, ) -> fmt::Result { let reg = reg.to_string(size); - let mem = mem.to_string(); + let mem = if f.alternate() { + alloc::format!("{mem:#}") + } else { + mem.to_string() + }; let suffix = match size { Size::Byte => "b", Size::Word => "w", @@ -570,6 +561,18 @@ pub mod display { size: Size, ) -> fmt::Result { let reg = rm.to_string(size); + // XED always states the implicit shift count, and drops the AT&T size + // suffix when the operand is a register. + if f.alternate() { + let op = rm.to_string_xed(size); + let name = match rm { + GprMem::Gpr(_) => mnemonic + .strip_suffix(['b', 'w', 'l', 'q']) + .unwrap_or(mnemonic), + GprMem::Mem(_) => mnemonic, + }; + return write!(f, "{name} $0x1, {op}"); + } match rm { GprMem::Gpr(_) => write!(f, "{mnemonic} $1, {reg}"), GprMem::Mem(_) => write!(f, "{mnemonic} {reg}"), @@ -578,8 +581,15 @@ pub mod display { pub fn jmpq_m(f: &mut fmt::Formatter<'_>, jmp: &inst::jmpq_m) -> fmt::Result { let inst::jmpq_m { rm64 } = jmp; - let rm64 = rm64.to_string(Size::Quadword); - write!(f, "jmpq *{rm64}") + // XED writes the target plainly, without the indirect `*`, and names a + // register target `jmp` rather than `jmpq`. + if f.alternate() { + let op = rm64.to_string_xed(Size::Quadword); + let name = if rm64.is_memory() { "jmpq" } else { "jmp" }; + return write!(f, "{name} {op}"); + } + let op = rm64.to_string(Size::Quadword); + write!(f, "jmpq *{op}") } pub fn jmp_d8(f: &mut fmt::Formatter<'_>, jmp: &inst::jmp_d8) -> fmt::Result { @@ -596,12 +606,14 @@ pub mod display { ($($mnemonic:tt = $j8:ident / $j32:ident;)*) => ($( pub fn $j8(f: &mut fmt::Formatter<'_>, jmp: &inst::$j8) -> fmt::Result { let inst::$j8 { imm8 } = jmp; - display_displacement(f, $mnemonic, i64::from(imm8.value()) + 2) + let name = if f.alternate() { jmp.xed_mnemonic() } else { $mnemonic.into() }; + display_displacement(f, &name, i64::from(imm8.value()) + 2) } pub fn $j32(f: &mut fmt::Formatter<'_>, jmp: &inst::$j32) -> fmt::Result { let inst::$j32 { imm32 } = jmp; - display_displacement(f, $mnemonic, i64::from(imm32.value()) + 6) + let name = if f.alternate() { jmp.xed_mnemonic() } else { $mnemonic.into() }; + display_displacement(f, &name, i64::from(imm32.value()) + 6) } )*) } @@ -630,7 +642,8 @@ pub mod display { mnemonic: &str, displacement: i64, ) -> fmt::Result { - if displacement >= 0 && displacement < 10 { + // XED always writes the target in hexadecimal. + if !f.alternate() && displacement >= 0 && displacement < 10 { write!(f, "{mnemonic} {displacement}") } else { write!(f, "{mnemonic} {displacement:#x}") diff --git a/cranelift/assembler-x64/src/fuzz.rs b/cranelift/assembler-x64/src/fuzz.rs index ef5f2dc499ac..eb44736d7839 100644 --- a/cranelift/assembler-x64/src/fuzz.rs +++ b/cranelift/assembler-x64/src/fuzz.rs @@ -18,8 +18,8 @@ use capstone::{Capstone, arch::BuildsCapstone, arch::BuildsCapstoneSyntax, arch: /// Take a random assembly instruction and check its encoding and /// pretty-printing against a known-good disassembler. /// -/// This uses Capstone as the disassembler oracle; see [`roundtrip_with`] for -/// the oracle-agnostic core. +/// This uses Capstone as the disassembler oracle; see `roundtrip_with` for the +/// oracle-agnostic core. /// /// # Panics /// @@ -34,7 +34,13 @@ pub fn roundtrip(inst: &Inst) { return; } - roundtrip_with(inst, "capstone", disassemble_capstone, capstone_matches); + roundtrip_with( + inst, + "capstone", + disassemble_capstone, + capstone_matches, + |i| format!("{i}"), + ); } /// Like [`roundtrip`], but uses Intel XED as the disassembler oracle instead of @@ -49,14 +55,20 @@ pub fn roundtrip(inst: &Inst) { /// See [`roundtrip`]. #[cfg(feature = "fuzz-xed")] pub fn roundtrip_xed(inst: &Inst) { - roundtrip_with(inst, "xed", disassemble_xed, xed_matches); + roundtrip_with(inst, "xed", disassemble_xed, xed_matches, |i| { + format!("{i:#}") + }); } /// The oracle-agnostic core of [`roundtrip`]: assemble `inst`, disassemble the /// resulting bytes with the provided `disassemble` oracle, and check that the -/// oracle's pretty-printed output matches the assembler's own `to_string`, -/// where "matches" is defined by the oracle-specific `matches` predicate -/// (`matches(expected_from_oracle, actual_from_assembler)`). +/// oracle's output matches how `render` prints the instruction, as judged by +/// the oracle-specific `matches` predicate. +/// +/// `render` selects the syntax to compare against: `{inst}` is the assembler's +/// own (what Cranelift's disassembly and the `precise-output` filetests use), +/// while `{inst:#}` is XED's. Printing directly in the oracle's dialect avoids +/// having to reconcile the two strings afterwards. /// /// The `oracle` name is only used to label diagnostic output on failure. fn roundtrip_with( @@ -64,6 +76,7 @@ fn roundtrip_with( oracle: &str, disassemble: impl Fn(&[u8], &Inst) -> String, matches: impl Fn(&str, &str) -> bool, + render: impl Fn(&Inst) -> String, ) { // Check that we can actually assemble this instruction. let assembled = assemble(inst); @@ -72,7 +85,7 @@ fn roundtrip_with( // Check that our pretty-printed output matches the known-good output. Trim // off the instruction offset first. let expected = expected.split_once(' ').unwrap().1; - let actual = inst.to_string(); + let actual = render(inst); if !matches(expected, &actual) { println!("> {inst}"); println!(" debug: {inst:x?}"); @@ -403,564 +416,14 @@ fn fix_up(dis: &str) -> alloc::borrow::Cow<'_, str> { replace_signed_immediates(&dis) } -/// Comparison predicate for the Intel XED oracle. -/// -/// XED decodes the same instructions as the assembler but prints them with a -/// number of different conventions. The differences we reconcile here are: -/// -/// - Cosmetic whitespace (XED's double space after the mnemonic, its lack of -/// spaces inside memory operands), an explicit SIB scale of 1, and the AT&T -/// indirect-branch marker `*` (as in `jmpq *%rax`). -/// - Numeric formatting: XED always prints hex while the assembler prints small -/// values in decimal. Normalized for `$` immediates, memory displacements, -/// and bare branch targets. -/// - XED omits the AT&T operand-size suffix on the mnemonic (`adc` vs `adcw`) -/// when an operand makes the width unambiguous, or conversely adds one the -/// assembler omits (`rcpssl` vs `rcpss`). -/// - XED appends a vector-length marker (`x`/`y`/`z` for 128/256/512-bit) to -/// some VEX/EVEX mnemonics (`vpalignrx` vs `vpalignr`). -/// - Legacy prefixes (`lock`, `rep*`, ...) printed as a leading token. -/// - Condition-code aliases (`cmovnb` vs `cmovae`). -/// - The AT&T/Intel spellings of the sign/zero-extend convert instructions -/// (`cltq` vs `cdqe`) and the move-with-extension instructions (`movzbl` vs -/// `movzxb`, `movslq` vs `movsxdl`). -/// - `movabs`(`q`) (assembler) vs plain `mov` (XED) for the imm64 move. -/// - Implicit operands the assembler prints but XED omits (the `%xmm0` mask of -/// the SSE4.1 variable blends, the `$1` count of shift/rotate-by-one). -/// - The SSE/AVX compare pseudo-ops, where the assembler bakes the predicate -/// into the mnemonic (`vcmpneqsd`) but XED uses a predicate immediate -/// (`vcmpsd $0x4, ...`). +/// Comparison predicate for the XED oracle. /// -/// Rather than blindly stripping suffixes from the assembler mnemonic--which -/// would corrupt mnemonics that legitimately end in those letters, like `mul` -/// or `call`--we use XED's mnemonic as ground truth: a suffix is only dropped -/// if doing so makes the two mnemonics exactly equal. +/// The assembler renders the instruction in XED's own dialect (`{inst:#}`), so +/// the two strings agree exactly apart from the extra padding XED inserts after +/// the mnemonic. #[cfg(feature = "fuzz-xed")] fn xed_matches(expected: &str, actual: &str) -> bool { - let actual = remove_after_semicolon(actual); - - // Normalize runs of whitespace to a single space, and drop spaces that - // follow a comma, so cosmetic spacing differences (XED's double space after - // the mnemonic, and its lack of spaces inside memory operands like - // `(%rsi,%rdx,2)`) don't matter. Also drop an explicit SIB scale of 1, - // which XED prints (`(%rbp,%rsi,1)`) but the assembler omits. Finally, drop - // the AT&T indirect-branch marker `*` (as in `jmpq *%rax`), which the - // assembler prints but XED does not; `*` has no other use in this syntax. - fn normalize_ws(s: &str) -> String { - let collapsed = s.split_whitespace().collect::>().join(" "); - collapsed - .replace(", ", ",") - .replace(",1)", ")") - .replace('*', "") - } - let expected = canonicalize_displacements(&canonicalize_immediates(&normalize_ws(expected))); - let actual = canonicalize_displacements(&canonicalize_immediates(&normalize_ws(actual))); - if expected == actual { - return true; - } - - // Split "mnemonic operands" into the leading mnemonic and the remainder. - fn split_mnemonic(s: &str) -> (&str, &str) { - match s.split_once(' ') { - Some((m, rest)) => (m, rest), - None => (s, ""), - } - } - - // Strip any leading legacy instruction prefixes (`lock`, `rep*`, ...) that - // both disassemblers print as a separate leading token, so the mnemonic - // normalization below operates on the real operation mnemonic rather than - // the prefix. Both sides decode the same bytes, so their prefixes agree. - fn strip_legacy_prefixes(s: &str) -> &str { - let mut s = s; - while let Some((head, rest)) = s.split_once(' ') { - if matches!( - head, - "lock" | "rep" | "repe" | "repz" | "repne" | "repnz" | "data16" | "bnd" | "notrack" - ) { - s = rest; - } else { - break; - } - } - s - } - let expected = strip_legacy_prefixes(&expected); - let actual = strip_legacy_prefixes(&actual); - - let (exp_mnemonic, exp_ops) = split_mnemonic(expected); - let (act_mnemonic, act_ops) = split_mnemonic(actual); - - // XED makes the implicit shift/rotate-by-one count explicit (`sarl $0x1, X` - // vs the assembler's `sarl X`); drop a leading `$0x1,` for that family so - // the operand lists line up. - fn is_shift_rotate(m: &str) -> bool { - const ROOTS: [&str; 8] = ["sal", "sar", "shl", "shr", "rol", "ror", "rcl", "rcr"]; - ROOTS.contains(&m) - || m.strip_suffix(['b', 'w', 'l', 'q']) - .is_some_and(|r| ROOTS.contains(&r)) - } - fn strip_shift_one<'a>(m: &str, ops: &'a str) -> &'a str { - if is_shift_rotate(m) { - if let Some(rest) = ops.strip_prefix("$0x1,") { - return rest; - } - } - ops - } - - // The SSE4.1 variable-blend instructions (`blendvps`, `blendvpd`, - // `pblendvb`) take an implicit `%xmm0` mask, which the assembler prints as - // an explicit leading operand but XED omits. This only ever appears on the - // assembler (`actual`) side, so strip it there alone--stripping it from XED - // too would corrupt cases where `%xmm0` is also a real explicit operand. - fn strip_implicit_xmm0<'a>(m: &str, ops: &'a str) -> &'a str { - if m.starts_with("blendv") || m.starts_with("pblendv") { - if let Some(rest) = ops.strip_prefix("%xmm0,") { - return rest; - } - } - ops - } - let exp_ops = strip_shift_one(exp_mnemonic, exp_ops); - let act_ops = strip_implicit_xmm0(act_mnemonic, strip_shift_one(act_mnemonic, act_ops)); - - // Canonicalize any whole-operand bare number (e.g. a relative branch target - // like `jnp 5` vs `jnp 0x5`) into the shared hex form. Operands that are - // not pure numbers (registers, memory references) are left untouched. - fn canonicalize_operand_numbers(ops: &str) -> String { - ops.split(',') - .map(|op| canonicalize_one_number(op).unwrap_or_else(|| op.to_string())) - .collect::>() - .join(",") - } - let exp_ops = canonicalize_operand_numbers(exp_ops); - let act_ops = canonicalize_operand_numbers(act_ops); - - // SSE/AVX compare pseudo-ops: the assembler bakes the comparison predicate - // into the mnemonic (`vcmpneqsd`) while XED uses the generic mnemonic plus a - // leading predicate immediate (`vcmpsd $0x4, ...`). Convert whichever side - // is a pseudo-op into the generic `mnemonic + $imm` form and compare. - let exp_cmp = split_cmp_pseudo(exp_mnemonic).map(|(b, n)| (b, prepend_predicate(n, &exp_ops))); - let act_cmp = split_cmp_pseudo(act_mnemonic).map(|(b, n)| (b, prepend_predicate(n, &act_ops))); - if exp_cmp.is_some() || act_cmp.is_some() { - let (exp_base, exp_cops) = - exp_cmp.unwrap_or_else(|| (exp_mnemonic.to_string(), exp_ops.clone())); - let (act_base, act_cops) = - act_cmp.unwrap_or_else(|| (act_mnemonic.to_string(), act_ops.clone())); - if exp_cops == act_cops - && (exp_base == act_base - || act_base.strip_suffix(['b', 'w', 'l', 'q']).as_deref() == Some(&exp_base) - || exp_base.strip_suffix(['b', 'w', 'l', 'q']).as_deref() == Some(&act_base) - || exp_base.strip_suffix(['x', 'y', 'z']).as_deref() == Some(&act_base)) - { - return true; - } - } - - if exp_ops != act_ops { - return false; - } - - if act_mnemonic == exp_mnemonic { - return true; - } - - // The assembler mnemonic is the XED mnemonic plus a single trailing - // operand-size suffix (`adcw` vs `adc`). - if act_mnemonic.strip_suffix(['b', 'w', 'l', 'q']) == Some(exp_mnemonic) { - return true; - } - - // ...or vice versa: XED adds an operand-size suffix that the assembler - // omits (`rcpssl` vs `rcpss`) when a memory operand makes the width - // otherwise unstated. - if exp_mnemonic.strip_suffix(['b', 'w', 'l', 'q']) == Some(act_mnemonic) { - return true; - } - - // The XED mnemonic is the assembler mnemonic plus a trailing vector-length - // marker (`vpalignrx` vs `vpalignr`). - if exp_mnemonic.strip_suffix(['x', 'y', 'z']) == Some(act_mnemonic) { - return true; - } - - // XED and the assembler may spell the same condition code differently - // (`cmovnb` vs `cmovae`). Canonicalize both mnemonics' condition codes and - // compare; a match means they name the same conditional instruction. - if let (Some(exp_canon), Some(act_canon)) = ( - canonical_condition_mnemonic(exp_mnemonic), - canonical_condition_mnemonic(act_mnemonic), - ) { - if exp_canon == act_canon { - return true; - } - } - - // The sign/zero-extending conversion instructions have distinct AT&T and - // Intel mnemonics for the same opcode; the assembler prints the AT&T form - // (`cltq`) while XED prints the Intel form (`cdqe`) even in AT&T syntax. - if canonical_convert_mnemonic(exp_mnemonic) == canonical_convert_mnemonic(act_mnemonic) { - return true; - } - - // The move-with-extension instructions spell their operand sizes - // differently: the assembler encodes both source and destination sizes in - // the mnemonic (`movzbl` = zero-extend byte to long) while XED uses a - // single source-size suffix (`movzxb`), or omits it entirely (`movzx`) when - // a register source already states the width. Canonicalize both--taking the - // source size from the mnemonic or, failing that, the source operand--and - // compare. The operand lists are already known equal here. - if let (Some(exp_canon), Some(act_canon)) = ( - canonical_movext_mnemonic(exp_mnemonic, &exp_ops), - canonical_movext_mnemonic(act_mnemonic, &act_ops), - ) { - if exp_canon == act_canon { - return true; - } - } - - // The assembler names the imm64/moffs move `movabs`(`q`); XED prints plain - // `mov`. Strip the `abs` marker, then allow the usual operand-size suffix - // difference (`movq` vs `mov`). - let exp_dm = exp_mnemonic - .strip_prefix("movabs") - .map(|s| format!("mov{s}")); - let act_dm = act_mnemonic - .strip_prefix("movabs") - .map(|s| format!("mov{s}")); - if exp_dm.is_some() || act_dm.is_some() { - let e = exp_dm.as_deref().unwrap_or(exp_mnemonic); - let a = act_dm.as_deref().unwrap_or(act_mnemonic); - if e == a - || a.strip_suffix(['b', 'w', 'l', 'q']) == Some(e) - || e.strip_suffix(['b', 'w', 'l', 'q']) == Some(a) - { - return true; - } - } - - false -} - -/// Map an SSE/AVX compare-predicate mnemonic fragment (as baked into the AT&T -/// pseudo-op mnemonics, e.g. `neq`) to its immediate predicate value. -#[cfg(feature = "fuzz-xed")] -fn cmp_predicate(name: &str) -> Option { - Some(match name { - "eq" => 0, - "lt" => 1, - "le" => 2, - "unord" => 3, - "neq" => 4, - "nlt" => 5, - "nle" => 6, - "ord" => 7, - "eq_uq" => 8, - "nge" => 9, - "ngt" => 10, - "false" => 11, - "neq_oq" => 12, - "ge" => 13, - "gt" => 14, - "true" => 15, - "eq_os" => 16, - "lt_oq" => 17, - "le_oq" => 18, - "unord_s" => 19, - "neq_us" => 20, - "nlt_uq" => 21, - "nle_uq" => 22, - "ord_s" => 23, - "eq_us" => 24, - "nge_uq" => 25, - "ngt_uq" => 26, - "false_os" => 27, - "neq_os" => 28, - "ge_oq" => 29, - "gt_oq" => 30, - "true_us" => 31, - _ => return None, - }) -} - -/// Recognize an SSE/AVX compare pseudo-op mnemonic (`cmpneqps`, `vcmpltsd`, -/// ...) and split it into its generic base mnemonic (`cmpps`, `vcmpsd`) and the -/// predicate immediate value. Returns `None` for any other mnemonic. -#[cfg(feature = "fuzz-xed")] -fn split_cmp_pseudo(m: &str) -> Option<(String, u8)> { - let (prefix, rest) = match m.strip_prefix('v') { - Some(r) => ("v", r), - None => ("", m), - }; - let rest = rest.strip_prefix("cmp")?; - for ty in ["ps", "pd", "ss", "sd"] { - if let Some(pred) = rest.strip_suffix(ty) { - if let Some(n) = cmp_predicate(pred) { - return Some((format!("{prefix}cmp{ty}"), n)); - } - } - } - None -} - -/// Prepend a predicate immediate (`$0xN`) to an operand list, matching the -/// generic-form operand ordering used by XED for the compare instructions. -#[cfg(feature = "fuzz-xed")] -fn prepend_predicate(n: u8, ops: &str) -> String { - if ops.is_empty() { - format!("$0x{n:x}") - } else { - format!("$0x{n:x},{ops}") - } -} - -/// The size letter implied by a source register operand (`%r11w` -> `w`), or -/// `None` for a memory operand (whose size cannot be read off the operand). -#[cfg(feature = "fuzz-xed")] -fn reg_source_size(op: &str) -> Option<&'static str> { - let reg = op.strip_prefix('%')?; - if reg.contains('(') { - return None; // memory operand - } - const B: &[&str] = &[ - "al", "bl", "cl", "dl", "sil", "dil", "spl", "bpl", "ah", "bh", "ch", "dh", - ]; - const W: &[&str] = &["ax", "bx", "cx", "dx", "si", "di", "sp", "bp"]; - const D: &[&str] = &["eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp"]; - if B.contains(®) || (reg.starts_with('r') && reg.ends_with('b')) { - Some("b") - } else if W.contains(®) || (reg.starts_with('r') && reg.ends_with('w')) { - Some("w") - } else if D.contains(®) || (reg.starts_with('r') && reg.ends_with('d')) { - Some("d") - } else { - Some("q") - } -} - -/// Canonicalize a move-with-zero/sign-extension mnemonic so the assembler's -/// two-size AT&T spelling and XED's spelling compare equal. -/// -/// The assembler writes `mov{z,s}` (e.g. `movzbl`, `movswq`). XED -/// writes `mov{z,s}x` (e.g. `movzxb`, `movsxd`) or, when a register source -/// already states the width, just `mov{z,s}x`. Both agree on the destination -/// via the register operand, so we reduce each to `mov{z,s}x` with the -/// source size taken from the mnemonic when present, else inferred from the -/// source operand `ops`; the 32-bit source is normalized (`l` and `d` both mean -/// doubleword). Returns `None` for any mnemonic that is not one of these. -#[cfg(feature = "fuzz-xed")] -fn canonical_movext_mnemonic(m: &str, ops: &str) -> Option { - fn norm_src(s: &str) -> &str { - match s { - "l" | "d" => "d", - other => other, - } - } - let source_op = ops.split(',').next().unwrap_or(ops); - for kind in ['z', 's'] { - let prefix = format!("mov{kind}"); - let Some(rest) = m.strip_prefix(&prefix) else { - continue; - }; - // XED form: `x`, optionally followed by a single source-size letter - // (`movzxb`, or bare `movzx`). `movsxd` may carry a redundant trailing - // operand-size suffix (`movsxdl` = movsxd + l). - if let Some(src) = rest.strip_prefix('x') { - let src = match src.strip_suffix(['b', 'w', 'l', 'q']) { - Some(s) if !s.is_empty() => s, - _ => src, - }; - return match src.len() { - 0 => reg_source_size(source_op).map(|s| format!("mov{kind}x{}", norm_src(s))), - 1 => Some(format!("mov{kind}x{}", norm_src(src))), - _ => None, - }; - } - // Assembler form: exactly a source-size then destination-size letter - // (`movzbl`). Anything else (e.g. `movsd`, `movsldup`) is not a - // move-with-extension mnemonic. - if rest.len() == 2 { - return Some(format!("mov{kind}x{}", norm_src(&rest[0..1]))); - } - return None; - } - None -} - -/// Canonicalize the sign/zero-extending "convert" instructions, whose AT&T and -/// Intel mnemonics differ for the same opcode (e.g. AT&T `cltq` vs Intel -/// `cdqe`). Returns the input unchanged if it is not one of these mnemonics. -#[cfg(feature = "fuzz-xed")] -fn canonical_convert_mnemonic(m: &str) -> &str { - match m { - "cbtw" | "cbw" => "cbw", - "cwtl" | "cwde" => "cwde", - "cltq" | "cdqe" => "cdqe", - "cwtd" | "cwd" => "cwd", - "cltd" | "cdq" => "cdq", - "cqto" | "cqo" => "cqo", - other => other, - } -} - -/// Canonicalize a conditional-instruction mnemonic so that different spellings -/// of the same condition code compare equal. -/// -/// x86 condition codes have multiple mnemonic aliases that denote the identical -/// flag test, e.g. `ae` (above-or-equal), `nb` (not-below), and `nc` -/// (not-carry) are the same condition. The assembler and XED may pick different -/// aliases, so for the conditional families (`cmov`, `set`, and the `j` -/// conditional jumps) we split off an optional trailing operand-size suffix, -/// map the condition code to a canonical representative, and return -/// `prefix + canonical-cc` (dropping the size suffix, which is already implied -/// by the operands that have been matched separately). -/// -/// Returns `None` if `m` is not a recognized conditional mnemonic, so callers -/// can fall through to other comparisons. -#[cfg(feature = "fuzz-xed")] -fn canonical_condition_mnemonic(m: &str) -> Option { - // Map every condition-code alias to a canonical representative. Aliases on - // the same line denote the same condition. - fn canonical_cc(cc: &str) -> Option<&'static str> { - Some(match cc { - "e" | "z" => "e", - "ne" | "nz" => "ne", - "b" | "c" | "nae" => "b", - "ae" | "nb" | "nc" => "ae", - "be" | "na" => "be", - "a" | "nbe" => "a", - "l" | "nge" => "l", - "ge" | "nl" => "ge", - "le" | "ng" => "le", - "g" | "nle" => "g", - "p" | "pe" => "p", - "np" | "po" => "np", - "o" => "o", - "no" => "no", - "s" => "s", - "ns" => "ns", - _ => return None, - }) - } - - // The conditional families we normalize. `j` must be tried last so that - // longer prefixes (`cmov`) are matched first. - for prefix in ["cmov", "set", "j"] { - let Some(rest) = m.strip_prefix(prefix) else { - continue; - }; - - // `rest` is the condition code, possibly followed by a single - // operand-size suffix (`cmovbq` = `cmov` + `b` + `q`). Only treat a - // trailing size character as a suffix when the remainder is itself a - // valid condition code; this avoids mis-parsing codes that genuinely - // end in a size-like letter (`nl`, `nb`). - let cc = match rest.strip_suffix(['b', 'w', 'l', 'q']) { - Some(stripped) if canonical_cc(stripped).is_some() => stripped, - _ => rest, - }; - - return canonical_cc(cc).map(|c| format!("{prefix}{c}")); - } - - None -} - -/// Rewrite every `$`-prefixed immediate in a disassembly string into a single -/// canonical form so that decimal-vs-hex and signedness differences between the -/// assembler and XED don't cause spurious mismatches. -/// -/// The assembler prints small immediates in decimal (`$1`) and larger ones in -/// hex (`$0xb143`), while XED always prints hex (`$0x1`). We parse each -/// immediate's numeric value (handling an optional leading `-` and `0x`) and -/// re-emit it as `$0x{:x}` of its `u64` two's-complement value. -#[cfg(feature = "fuzz-xed")] -fn canonicalize_immediates(dis: &str) -> String { - let mut out = String::with_capacity(dis.len()); - let mut rest = dis; - while let Some(idx) = rest.find('$') { - out.push_str(&rest[..idx]); - // Everything after the '$'. - let after = &rest[idx + 1..]; - let (neg, num) = match after.strip_prefix('-') { - Some(n) => (true, n), - None => (false, after), - }; - let (radix, digits) = match num.strip_prefix("0x") { - Some(d) => (16, d), - None => (10, num), - }; - let n = digits.chars().take_while(|c| c.is_digit(radix)).count(); - if n == 0 { - // Not actually an immediate we can parse; keep the '$' literally. - out.push('$'); - rest = after; - continue; - } - let (value_str, tail) = digits.split_at(n); - let value = u64::from_str_radix(value_str, radix).unwrap_or(0); - let value = if neg { value.wrapping_neg() } else { value }; - out.push_str(&format!("$0x{value:x}")); - rest = tail; - } - out.push_str(rest); - out -} - -/// Parse a complete numeric token of the form `[-]?(0x)?` and re-emit -/// it in the canonical `0x{:x}` form used throughout XED normalization. Returns -/// `None` if `s` is not entirely a valid number. -#[cfg(feature = "fuzz-xed")] -fn canonicalize_one_number(s: &str) -> Option { - let (neg, num) = match s.strip_prefix('-') { - Some(n) => (true, n), - None => (false, s), - }; - let (radix, digits) = match num.strip_prefix("0x") { - Some(d) => (16, d), - None => (10, num), - }; - if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { - return None; - } - let value = u64::from_str_radix(digits, radix).ok()?; - let value = if neg { value.wrapping_neg() } else { value }; - Some(format!("0x{value:x}")) -} - -/// Rewrite every numeric memory displacement--the number immediately preceding -/// a `(` base/index group--into the same canonical `0x{:x}` form used for -/// immediates, so decimal-vs-hex differences in displacements don't cause -/// spurious mismatches (`-8(%rax)` vs `-0x8(%rax)`). -#[cfg(feature = "fuzz-xed")] -fn canonicalize_displacements(dis: &str) -> String { - let mut out = String::with_capacity(dis.len()); - for ch in dis.chars() { - if ch != '(' { - out.push(ch); - continue; - } - // Walk backwards over any trailing displacement token in `out`: - // hex digits, an optional `0x` prefix, and an optional leading `-`. - let b = out.as_bytes(); - let mut start = out.len(); - while start > 0 && b[start - 1].is_ascii_hexdigit() { - start -= 1; - } - if start >= 2 && &out[start - 2..start] == "0x" { - start -= 2; - } - if start > 0 && b[start - 1] == b'-' { - start -= 1; - } - if let Some(canon) = canonicalize_one_number(&out[start..]) { - out.truncate(start); - out.push_str(&canon); - } - out.push('('); - } - out + expected.split_whitespace().eq(actual.split_whitespace()) } /// Fuzz-specific registers. @@ -1111,13 +574,8 @@ mod test { /// Same as [`smoke`], but exercises the Intel XED oracle. Only available /// with the `fuzz-xed` feature. /// - /// XED decodes the same bytes as the assembler but pretty-prints them with - /// a number of different conventions; the [`xed_matches`] predicate - /// reconciles them (operand-size suffixes, vector-length markers, - /// whitespace, immediate/displacement/branch-target formatting, explicit - /// SIB scales, legacy prefixes, condition-code aliases, the AT&T/Intel - /// convert mnemonics, `movabs`, the move-with-extension mnemonics, implicit - /// operands, and the compare pseudo-ops). Run explicitly with + /// The instruction is printed in XED's dialect (`{inst:#}`) so that the two + /// can be compared directly. Run explicitly with /// `cargo test --features fuzz-xed -- smoke_xed`. #[cfg(feature = "fuzz-xed")] #[test] diff --git a/cranelift/assembler-x64/src/mem.rs b/cranelift/assembler-x64/src/mem.rs index e104f9dfd15f..5dc033c0d907 100644 --- a/cranelift/assembler-x64/src/mem.rs +++ b/cranelift/assembler-x64/src/mem.rs @@ -104,21 +104,24 @@ impl From for AmodeOffset { impl core::fmt::LowerHex for AmodeOffset { fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { // This rather complex implementation is necessary to match how - // `capstone` pretty-prints memory immediates. + // `capstone` pretty-prints memory immediates; XED (the alternate form) + // always uses hexadecimal. if self.0 == 0 { return Ok(()); } if self.0 < 0 { write!(f, "-")?; } - if self.0 > 9 || self.0 < -9 { + if f.alternate() || self.0 > 9 || self.0 < -9 { write!(f, "0x")?; } let abs = match self.0.checked_abs() { Some(i) => i, None => -2_147_483_648, }; - core::fmt::LowerHex::fmt(&abs, f) + // Not `LowerHex::fmt(&abs, f)`: `f` may carry the alternate flag, which + // would make the integer emit a second `0x`. + write!(f, "{abs:x}") } } @@ -177,12 +180,18 @@ pub enum DeferredTarget { impl core::fmt::Display for Amode { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let pointer_width = Size::Quadword; + // XED prints no space after the commas and always states the scale. + let xed = f.alternate(); match self { Amode::ImmReg { simm32, base, .. } => { // Note: size is always 8; the address is 64 bits, // even if the addressed operand is smaller. let base = base.to_string(Some(pointer_width)); - write!(f, "{simm32:x}({base})") + if xed { + write!(f, "{simm32:#x}({base})") + } else { + write!(f, "{simm32:x}({base})") + } } Amode::ImmRegRegShift { simm32, @@ -194,7 +203,9 @@ impl core::fmt::Display for Amode { let base = base.to_string(Some(pointer_width)); let index = index.to_string(pointer_width); let shift = scale.shift(); - if shift > 1 { + if xed { + write!(f, "{simm32:#x}({base},{index},{shift})") + } else if shift > 1 { write!(f, "{simm32:x}({base}, {index}, {shift})") } else { write!(f, "{simm32:x}({base}, {index})") @@ -265,6 +276,12 @@ pub enum GprMem { } impl GprMem { + /// Whether this operand is a memory reference. + #[must_use] + pub fn is_memory(&self) -> bool { + matches!(self, GprMem::Mem(_)) + } + /// Pretty-print the operand. pub fn to_string(&self, size: Size) -> String { match self { @@ -273,6 +290,14 @@ impl GprMem { } } + /// Pretty-print the operand in XED's dialect; see [`Amode`]'s `Display`. + pub fn to_string_xed(&self, size: Size) -> String { + match self { + GprMem::Gpr(gpr) => gpr.to_string(Some(size)), + GprMem::Mem(amode) => alloc::format!("{amode:#}"), + } + } + /// Return the [`RexPrefix`] for each variant of this [`GprMem`]. #[must_use] pub(crate) fn as_rex_prefix(&self, enc_reg: u8, has_w_bit: bool, uses_8bit: bool) -> RexPrefix { @@ -334,6 +359,12 @@ pub enum XmmMem { } impl XmmMem { + /// Whether this operand is a memory reference. + #[must_use] + pub fn is_memory(&self) -> bool { + matches!(self, XmmMem::Mem(_)) + } + /// Pretty-print the operand. pub fn to_string(&self) -> String { match self { @@ -342,6 +373,14 @@ impl XmmMem { } } + /// Pretty-print the operand in XED's dialect; see [`Amode`]'s `Display`. + pub fn to_string_xed(&self) -> String { + match self { + XmmMem::Xmm(xmm) => xmm.to_string(None), + XmmMem::Mem(amode) => alloc::format!("{amode:#}"), + } + } + /// Return the [`RexPrefix`] for each variant of this [`XmmMem`]. #[must_use] pub(crate) fn as_rex_prefix(&self, enc_reg: u8, has_w_bit: bool, uses_8bit: bool) -> RexPrefix { From bcb8aaa2407aef866c6ec482d55c5ad709b6dd8e Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Thu, 30 Jul 2026 11:45:47 -0700 Subject: [PATCH 7/8] cranelift-assembler-x64: only build XED on x86_64 Linux The `fuzz-xed` feature builds Intel XED from C source, and `ci/run-tests.py` runs `cargo test --workspace --all-features`, which enabled the feature on every target in the test matrix. XED's build system is particular about its environment and failed in two different ways there: - on s390x it rejects the host outright with `Unknown cpu s390x`; - on macOS it selects `llvm-ar` as the archiver (because the compiler is clang) and aborts with `FileNotFoundError: 'llvm-ar'`, since that binary is not on the runner's PATH. The Windows jobs were cancelled before they ran in both cases, so whether XED builds there is unknown. The oracle only ever runs on x86_64 Linux -- the `fuzz-xed` CI job and OSS-Fuzz -- so restrict the dependency to that target and gate the corresponding code paths to match. Capstone remains the oracle everywhere else and the crate's other tests continue to run across the full matrix. --- cranelift/assembler-x64/Cargo.toml | 12 ++++++++++-- cranelift/assembler-x64/src/fuzz.rs | 8 ++++---- fuzz/fuzz_targets/misc.rs | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cranelift/assembler-x64/Cargo.toml b/cranelift/assembler-x64/Cargo.toml index 2e87fde098ec..b93f375f30ef 100644 --- a/cranelift/assembler-x64/Cargo.toml +++ b/cranelift/assembler-x64/Cargo.toml @@ -9,6 +9,14 @@ rust-version.workspace = true [dependencies] arbitrary = { workspace = true, features = ["derive"], optional = true } capstone = { workspace = true, optional = true } + +# XED is built from C source by its own Python build system, which is picky +# about its environment: it only recognizes x86 host CPUs, and it invokes LLVM +# toolchain binaries (e.g. `llvm-ar`) that are not present on every runner. +# Restrict it to x86_64 Linux -- the only configuration where the oracle +# actually runs, i.e. the `fuzz-xed` CI job and OSS-Fuzz -- so that +# `--all-features` builds keep working elsewhere (see `ci/run-tests.py`). +[target.'cfg(all(target_arch = "x86_64", target_os = "linux"))'.dependencies] xed-sys = { workspace = true, optional = true } [dev-dependencies] @@ -26,6 +34,6 @@ workspace = true fuzz = ['dep:arbitrary', 'dep:capstone'] # Adds Intel XED as a second, optional disassembler oracle for the roundtrip # fuzzer. This is additive on top of `fuzz` (Capstone remains the default -# oracle); XED is only built when this feature is explicitly enabled. Note that -# building XED requires Python 3.9+ and a C compiler. +# oracle); XED is only built when this feature is explicitly enabled, and only +# on x86_64 Linux. Note that building XED requires Python 3.9+ and a C compiler. fuzz-xed = ['fuzz', 'dep:xed-sys'] diff --git a/cranelift/assembler-x64/src/fuzz.rs b/cranelift/assembler-x64/src/fuzz.rs index eb44736d7839..3efe0acfc472 100644 --- a/cranelift/assembler-x64/src/fuzz.rs +++ b/cranelift/assembler-x64/src/fuzz.rs @@ -53,7 +53,7 @@ pub fn roundtrip(inst: &Inst) { /// # Panics /// /// See [`roundtrip`]. -#[cfg(feature = "fuzz-xed")] +#[cfg(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux"))] pub fn roundtrip_xed(inst: &Inst) { roundtrip_with(inst, "xed", disassemble_xed, xed_matches, |i| { format!("{i:#}") @@ -228,7 +228,7 @@ fn disassemble_capstone(assembled: &[u8], original: &Inst) -> String { /// same shape as [`disassemble_capstone`] (a leading offset token, a space, /// then the AT&T-syntax instruction) so that [`roundtrip_with`] can compare it /// uniformly. -#[cfg(feature = "fuzz-xed")] +#[cfg(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux"))] fn disassemble_xed(assembled: &[u8], original: &Inst) -> String { use core::ffi::c_void; use std::sync::Once; @@ -421,7 +421,7 @@ fn fix_up(dis: &str) -> alloc::borrow::Cow<'_, str> { /// The assembler renders the instruction in XED's own dialect (`{inst:#}`), so /// the two strings agree exactly apart from the extra padding XED inserts after /// the mnemonic. -#[cfg(feature = "fuzz-xed")] +#[cfg(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux"))] fn xed_matches(expected: &str, actual: &str) -> bool { expected.split_whitespace().eq(actual.split_whitespace()) } @@ -577,7 +577,7 @@ mod test { /// The instruction is printed in XED's dialect (`{inst:#}`) so that the two /// can be compared directly. Run explicitly with /// `cargo test --features fuzz-xed -- smoke_xed`. - #[cfg(feature = "fuzz-xed")] + #[cfg(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux"))] #[test] fn smoke_xed() { let count = AtomicUsize::new(0); diff --git a/fuzz/fuzz_targets/misc.rs b/fuzz/fuzz_targets/misc.rs index d1c46075182f..7daa41144891 100644 --- a/fuzz/fuzz_targets/misc.rs +++ b/fuzz/fuzz_targets/misc.rs @@ -93,13 +93,13 @@ fn assembler_roundtrip(u: Unstructured<'_>) -> Result<()> { /// `run_fuzzers!` regardless so that the input-byte discriminants of the other /// fuzzers stay stable. fn assembler_roundtrip_xed(u: Unstructured<'_>) -> Result<()> { - #[cfg(feature = "fuzz-xed")] + #[cfg(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux"))] { use cranelift_assembler_x64::{Inst, fuzz}; let inst: Inst = Arbitrary::arbitrary_take_rest(u)?; fuzz::roundtrip_xed(&inst); } - #[cfg(not(feature = "fuzz-xed"))] + #[cfg(not(all(feature = "fuzz-xed", target_arch = "x86_64", target_os = "linux")))] let _ = u; Ok(()) } From 23fa9d970e90d9d158197595fa1d5d2f326f39d4 Mon Sep 17 00:00:00 2001 From: Johnnie Birch Date: Thu, 30 Jul 2026 15:52:13 -0700 Subject: [PATCH 8/8] prtest:full