diff --git a/Cargo.toml b/Cargo.toml index 8352e328..afeab4aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,9 +58,19 @@ serde = [ ] tracing = ["serde"] debug-print = [] +# Floating point support. NOT officially supported and NOT for production builds: this exists only +# for the e2e testing suite and the fuzzer, which need real float semantics for the wasm spec tests +# and for wasmtime differential runs. +# +# WARNING: this feature changes emitted bytecode, not just runtime behaviour. Without it every +# float instruction is compiled to `Trap(IllegalOpcode)`, so an `fpu` build and a default build +# produce different modules (and different module hashes) for the same wasm input. See +# `impl_fpu_opcode!` in `src/isa/mod.rs` and `CompilationConfig::codegen_identity`. fpu = [] wasmtime = ["dep:wasmtime", "dep:lru"] cache-compiled-artifacts = ["wasmtime", "dep:directories"] +# Relaxations required by the e2e testing suite (spectest globals, funcref/externref values). +# Test-only, never for production builds. e2e = [] pooling-allocator = [] full-wasm-mode = ["wasmtime?/full-wasm-mode"] diff --git a/docs/module-format.md b/docs/module-format.md index c11cc64b..8d07aba6 100644 --- a/docs/module-format.md +++ b/docs/module-format.md @@ -38,6 +38,36 @@ After header, fields are encoded in this exact order: - Feature combinations (`fpu`, etc.) alter executable surface and should be pinned. - Legacy support currently handles missing `source_pc` by defaulting to `0`. +## Codegen determinism + +The header version (`0x01`) identifies the *wire format*, not the compiler configuration. The same +wasm input does not compile to the same bytes under every build, and the encoded module records +nothing about the build that produced it. + +Inputs that change emitted bytecode: + +- **`CompilationConfig` flags** — `code_snippets` decides which functions are emitted and therefore + shifts every downstream instruction offset; `consume_fuel`, `consume_fuel_for_bulk_ops`, + `consume_fuel_for_params_and_locals`, and `builtins_consume_fuel` decide where fuel charges are + injected; `max_allowed_memory_pages` and `default_imported_global_value` are baked into the + emitted code; `state_router`, `entrypoint_name`, and `import_linker` shape the entrypoint and the + syscall mapping. The `allow_*` flags relax validation and decide which inputs compile at all. +- **The `fpu` cargo feature** — off (the default), every float instruction is compiled to + `Trap(IllegalOpcode)`; on, real float opcodes are emitted. Floating point is not officially + supported: `fpu` exists for the e2e suite and the fuzzer only and must not be enabled in a + production build. + +Features that only change the host-side surface (`std`, `serde`, `wasmtime`, `debug-print`, …) do +not affect the emitted bytes. + +`CompilationConfig::codegen_identity()` hashes all of the above — the codegen-relevant config fields +plus the compile-time feature set of the compiling binary — into a 32-byte fingerprint. Compilers +agreeing on this value produce identical bytecode for a given wasm input. The fingerprint is **not** +part of the wire format, so a host that addresses modules by hash must carry the identity alongside +the bytecode and reject a module whose producer identity does not match its own. Embedding the +identity in the header would require a `RWASM_VERSION_V2` bump and would change the bytes (and +hashes) of every module. + ## Constructor/custom-section note Constructor parameter conventions are handled in `src/types/constructor_params.rs`. diff --git a/src/compiler/codegen_identity.rs b/src/compiler/codegen_identity.rs new file mode 100644 index 00000000..50813fdb --- /dev/null +++ b/src/compiler/codegen_identity.rs @@ -0,0 +1,346 @@ +//! Codegen identity: a stable fingerprint of everything that changes emitted rwasm bytecode. +//! +//! The same wasm input does not compile to the same rwasm bytecode under every build: +//! [`CompilationConfig`] flags decide which functions are emitted and where fuel charges are +//! injected, and the `fpu` cargo feature decides whether float instructions are lowered to real +//! opcodes or to `Trap(IllegalOpcode)`. None of that is recorded in the serialized module (see +//! `docs/module-format.md`), so two differently built compilers silently produce different bytes +//! for one contract. +//! +//! [`CompilationConfig::codegen_identity`] hashes those inputs into a single 32-byte value. +//! Hosts that distribute or address compiled modules by hash should pin this identity next to the +//! bytecode and reject a module whose producer identity does not match the local one — a clean +//! error instead of a silent behavioral difference. + +use crate::{ + intrinsic::Intrinsic, CompilationConfig, ImportLinker, ImportName, Opcode, StateRouterConfig, +}; +use alloc::vec::Vec; +use rwasm_fuel_policy::SyscallFuelParams; +use tiny_keccak::{Hasher, Keccak}; +use wasmparser::ValType; + +/// Domain separator for the codegen identity hash. +/// +/// Bump the trailing version whenever the preimage layout below changes; it keeps identities +/// produced by different rwasm releases from colliding. +const CODEGEN_IDENTITY_DOMAIN: &[u8] = b"rwasm.codegen-identity.v1"; + +/// The `fpu` cargo feature is enabled. +/// +/// Float instructions are lowered to real opcodes instead of `Trap(IllegalOpcode)`, so every +/// module compiled from float-using wasm differs from a default build. See the note on +/// `impl_fpu_opcode!` in `src/isa/mod.rs`: `fpu` exists for the e2e/fuzz suites only. +pub const CODEGEN_FEATURE_FPU: u64 = 1 << 0; + +/// Returns the set of compile-time cargo features that affect emitted bytecode. +/// +/// Only features that change codegen belong here; features that change the host-side surface +/// (`std`, `serde`, `wasmtime`, …) do not affect the bytes and are deliberately excluded. +pub const fn codegen_feature_set() -> u64 { + let mut features = 0u64; + if cfg!(feature = "fpu") { + features |= CODEGEN_FEATURE_FPU; + } + features +} + +impl CompilationConfig { + /// Returns a 32-byte fingerprint of every input that affects emitted bytecode: the + /// codegen-relevant fields of this config plus the compile-time feature set of the compiling + /// binary ([`codegen_feature_set`]). + /// + /// Two compilers agreeing on this value compile any given wasm input to identical rwasm bytes; + /// two compilers disagreeing on it may not. The value is not part of the module wire format, + /// so a host that cares about reproducibility must carry it alongside the bytecode itself. + pub fn codegen_identity(&self) -> [u8; 32] { + let mut hasher = IdentityHasher::new(); + + hasher.bytes(CODEGEN_IDENTITY_DOMAIN); + hasher.u64(codegen_feature_set()); + + hasher.opt(self.state_router.as_ref(), IdentityHasher::state_router); + hasher.opt(self.entrypoint_name.as_deref(), |hasher, name| { + hasher.bytes(name.as_bytes()) + }); + hasher.opt(self.import_linker.as_deref(), IdentityHasher::import_linker); + hasher.opt(self.default_imported_global_value.as_ref(), |hasher, v| { + hasher.u64(*v as u64) + }); + + hasher.bool(self.allow_malformed_entrypoint_func_type); + hasher.bool(self.builtins_consume_fuel); + hasher.bool(self.consume_fuel); + hasher.bool(self.code_snippets); + hasher.bool(self.consume_fuel_for_bulk_ops); + hasher.bool(self.consume_fuel_for_params_and_locals); + hasher.bool(self.allow_func_ref_function_types); + hasher.bool(self.allow_start_section); + hasher.u32(self.max_allowed_memory_pages); + + hasher.finalize() + } +} + +/// Keccak-256 over a length-prefixed preimage. +/// +/// Every variable-length element is written with its length first, so no two distinct configs can +/// serialize into the same byte stream. +struct IdentityHasher { + keccak: Keccak, +} + +impl IdentityHasher { + fn new() -> Self { + Self { + keccak: Keccak::v256(), + } + } + + fn finalize(self) -> [u8; 32] { + let mut output = [0u8; 32]; + self.keccak.finalize(&mut output); + output + } + + fn u8(&mut self, value: u8) { + self.keccak.update(&[value]); + } + + fn u32(&mut self, value: u32) { + self.keccak.update(&value.to_le_bytes()); + } + + fn u64(&mut self, value: u64) { + self.keccak.update(&value.to_le_bytes()); + } + + fn bool(&mut self, value: bool) { + self.u8(value as u8); + } + + fn bytes(&mut self, value: &[u8]) { + self.u64(value.len() as u64); + self.keccak.update(value); + } + + /// Hashes an optional value as a presence tag followed by the value itself. + fn opt(&mut self, value: Option, hash_value: impl FnOnce(&mut Self, T)) { + match value { + Some(value) => { + self.u8(1); + hash_value(self, value); + } + None => self.u8(0), + } + } + + fn opcode(&mut self, opcode: &Opcode) { + let encoded: Vec = bincode::encode_to_vec(opcode, bincode::config::legacy()) + .unwrap_or_else(|_| unreachable!("rwasm: failed to encode opcode")); + self.bytes(&encoded); + } + + fn val_types(&mut self, val_types: &[ValType]) { + self.u64(val_types.len() as u64); + for val_type in val_types { + self.u8(match val_type { + ValType::I32 => 0, + ValType::I64 => 1, + ValType::F32 => 2, + ValType::F64 => 3, + ValType::V128 => 4, + ValType::FuncRef => 5, + ValType::ExternRef => 6, + }); + } + } + + /// State order is part of the routing code, so states are hashed in declaration order. + fn state_router(&mut self, state_router: &StateRouterConfig) { + self.u64(state_router.states.len() as u64); + for (name, func_idx) in state_router.states.iter() { + self.bytes(name.as_bytes()); + self.u32(*func_idx); + } + self.opt(state_router.opcode.as_ref(), Self::opcode); + } + + /// Entries are hashed in sorted import-name order because the linker itself is backed by a + /// hash map, whose iteration order is not stable across runs. + fn import_linker(&mut self, import_linker: &ImportLinker) { + let symbols = import_linker.find_symbols(); + self.u64(symbols.len() as u64); + for import_name in symbols.iter() { + self.import_name(import_name); + let entity = import_linker + .resolve_by_import_name(import_name) + .unwrap_or_else(|| unreachable!("rwasm: import linker symbol without an entity")); + self.u32(entity.sys_func_idx); + self.val_types(entity.params); + self.val_types(entity.result); + self.syscall_fuel_params(&entity.syscall_fuel_param); + self.opt(entity.intrinsic.as_ref(), Self::intrinsic); + } + } + + fn import_name(&mut self, import_name: &ImportName) { + self.bytes(import_name.module().as_bytes()); + self.bytes(import_name.name().as_bytes()); + } + + fn intrinsic(&mut self, intrinsic: &Intrinsic) { + match intrinsic { + Intrinsic::Replace(opcodes) => { + self.u8(0); + self.u64(opcodes.len() as u64); + for opcode in opcodes { + self.opcode(opcode); + } + } + Intrinsic::Remove => self.u8(1), + } + } + + fn syscall_fuel_params(&mut self, params: &SyscallFuelParams) { + match params { + SyscallFuelParams::None => self.u8(0), + SyscallFuelParams::Const(fuel) => { + self.u8(1); + self.u64(*fuel); + } + SyscallFuelParams::LinearFuel(params) => { + self.u8(2); + self.u32(params.base_fuel); + self.u32(params.param_index); + self.u32(params.word_cost); + } + SyscallFuelParams::QuadraticFuel(params) => { + self.u8(3); + self.u32(params.local_depth); + self.u32(params.word_cost); + self.u32(params.divisor); + self.u32(params.fuel_denom_rate); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{boxed::Box, sync::Arc}; + use rwasm_fuel_policy::LinearFuelParams; + + fn import_linker(names: &[(&str, &str, u32)]) -> ImportLinker { + let mut import_linker = ImportLinker::default(); + for (module, field, sys_func_idx) in names { + import_linker.insert_function( + ImportName::new(module, field), + *sys_func_idx, + SyscallFuelParams::LinearFuel(LinearFuelParams { + base_fuel: 1, + param_index: 0, + word_cost: 2, + }), + &[ValType::I32], + &[ValType::I64], + ); + } + import_linker + } + + /// Pins the preimage layout: the identity of the default config must not drift silently, + /// and it must differ between an `fpu` build and a default one. Update these digests together + /// with the `CODEGEN_IDENTITY_DOMAIN` version whenever the preimage changes on purpose. + #[test] + fn default_identity_is_pinned() { + let expected = if cfg!(feature = "fpu") { + hex_literal::hex!("551dea720f2815bc82b4cd8ff7a4fcc4a68ea457c2f4b2793fc190ff8d2ee277") + } else { + hex_literal::hex!("72e113e7f5d3ac82ea0588d7cda7eaab3ea613154090b4a1b8d5f7621f475a66") + }; + assert_eq!(CompilationConfig::default().codegen_identity(), expected); + } + + #[test] + fn identity_is_stable_for_equal_configs() { + assert_eq!( + CompilationConfig::default().codegen_identity(), + CompilationConfig::default().codegen_identity() + ); + } + + #[test] + fn identity_tracks_codegen_relevant_flags() { + let default_identity = CompilationConfig::default().codegen_identity(); + let modified = [ + CompilationConfig::default().with_code_snippets(false), + CompilationConfig::default().with_consume_fuel(false), + CompilationConfig::default().with_consume_fuel_for_bulk_ops(false), + CompilationConfig::default().with_consume_fuel_for_params_and_locals(false), + CompilationConfig::default().with_builtins_consume_fuel(true), + CompilationConfig::default().with_allow_malformed_entrypoint_func_type(true), + CompilationConfig::default().with_allow_func_ref_function_types(true), + CompilationConfig::default().with_allow_start_section(true), + CompilationConfig::default().with_max_allowed_memory_pages(1), + CompilationConfig::default().with_default_imported_global_value(0), + CompilationConfig::default().with_entrypoint_name("main".into()), + CompilationConfig::default().with_state_router(StateRouterConfig { + states: Box::new([("deploy".into(), 0)]), + opcode: Some(Opcode::I32Const(0.into())), + }), + CompilationConfig::default().with_import_linker(Arc::new(import_linker(&[( + "env", + "keccak256", + 0, + )]))), + ]; + for config in modified { + assert_ne!( + config.codegen_identity(), + default_identity, + "identity must change for config {config:?}" + ); + } + } + + #[test] + fn identity_ignores_import_linker_insertion_order() { + let forward = import_linker(&[("env", "a", 0), ("env", "b", 1), ("other", "a", 2)]); + let backward = import_linker(&[("other", "a", 2), ("env", "b", 1), ("env", "a", 0)]); + let identity = |linker: ImportLinker| { + CompilationConfig::default() + .with_import_linker(Arc::new(linker)) + .codegen_identity() + }; + assert_eq!(identity(forward), identity(backward)); + } + + #[test] + fn identity_separates_state_router_ordering() { + let router = |states: [(&str, u32); 2]| { + CompilationConfig::default() + .with_state_router(StateRouterConfig { + states: states + .iter() + .map(|(name, idx)| ((*name).into(), *idx)) + .collect(), + opcode: None, + }) + .codegen_identity() + }; + assert_ne!( + router([("deploy", 0), ("main", 1)]), + router([("main", 1), ("deploy", 0)]) + ); + } + + #[test] + fn feature_set_reports_fpu() { + assert_eq!( + codegen_feature_set() & CODEGEN_FEATURE_FPU != 0, + cfg!(feature = "fpu") + ); + } +} diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index ba93be0b..4d67f19c 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -1,4 +1,5 @@ mod block_fuel; +mod codegen_identity; mod compiled_expr; mod config; mod control_flow; @@ -17,6 +18,7 @@ mod utils; mod value_stack; pub use self::{ + codegen_identity::{codegen_feature_set, CODEGEN_FEATURE_FPU}, config::{CompilationConfig, StateRouterConfig}, error::CompilationError, parser::ModuleParser, diff --git a/src/isa/mod.rs b/src/isa/mod.rs index ef7fed16..0d154832 100644 --- a/src/isa/mod.rs +++ b/src/isa/mod.rs @@ -78,6 +78,18 @@ macro_rules! impl_basic_opcode { }; } +/// Emits a float opcode, or `Trap(IllegalOpcode)` when the `fpu` feature is off. +/// +/// WARNING: floating point is NOT officially supported by rwasm. The `fpu` feature exists only for +/// the e2e testing suite (`e2e/Cargo.toml`) and the fuzzer (`fuzz/Cargo.toml`), which need real +/// float semantics to run the wasm spec tests and to compare against wasmtime. It is deliberately +/// absent from `default` and must never be enabled in a production build. +/// +/// The substitution happens at *compile* time, so the feature changes the emitted bytecode, not +/// just runtime behaviour: an `fpu` build and a default build produce different modules (and +/// different module hashes) for the same float-using wasm input. The matching runtime split lives +/// in `src/vm/executor.rs`. `CompilationConfig::codegen_identity` folds the feature into a +/// fingerprint so hosts can detect the mismatch instead of silently executing foreign bytecode. macro_rules! impl_fpu_opcode { ($opcode:ident($data_type:ident)) => { paste::paste! { diff --git a/src/vm/executor.rs b/src/vm/executor.rs index e3b74cfe..39b2aa8c 100644 --- a/src/vm/executor.rs +++ b/src/vm/executor.rs @@ -284,6 +284,10 @@ impl<'a, T> RwasmExecutor<'a, T> { TableInit(imm) => self.visit_table_init(imm)?, ElemDrop(imm) => self.visit_element_drop(imm), + // Floating point is not officially supported: the `fpu` feature is only for the e2e + // testing suite and the fuzzer, and a default build traps on every float opcode. Note + // that the compiler applies the same split (see `impl_fpu_opcode!` in + // `src/isa/mod.rs`), so a default build never even emits these opcodes. #[cfg(feature = "fpu")] opcode => self.exec_fpu_opcode(opcode)?, #[cfg(not(feature = "fpu"))]