From 8805fb321a7f89499d76b9481e82c90b920716a6 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 31 Jul 2026 11:45:20 +0100 Subject: [PATCH 1/4] test: expose runner archive and float regressions --- executor/src/runners/parse.rs | 66 ++++++++ .../genvm-floats-to-soft/tests/regressions.rs | 147 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 runners/support/tools/genvm-floats-to-soft/tests/regressions.rs diff --git a/executor/src/runners/parse.rs b/executor/src/runners/parse.rs index 11a2e9c8..43745e46 100644 --- a/executor/src/runners/parse.rs +++ b/executor/src/runners/parse.rs @@ -92,3 +92,69 @@ fn code_to_archive_from_text(code: bytes::Bytes) -> rt::errors::Result bytes::Bytes { + assert!(name.len() <= 100); + assert!(prefix.len() <= 155); + + let mut header = [0u8; BLOCK_SIZE]; + header[..name.len()].copy_from_slice(name); + header[100..108].copy_from_slice(b"0000644\0"); + header[108..116].copy_from_slice(b"0000000\0"); + header[116..124].copy_from_slice(b"0000000\0"); + let size = format!("{:011o}\0", contents.len()); + header[124..136].copy_from_slice(size.as_bytes()); + header[136..148].copy_from_slice(b"00000000000\0"); + header[148..156].fill(b' '); + header[156] = b'0'; + header[257..265].copy_from_slice(b"ustar\x0000".as_slice()); + header[345..345 + prefix.len()].copy_from_slice(prefix); + let checksum: u32 = header.iter().map(|byte| *byte as u32).sum(); + header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes()); + + let padded_contents = contents.len().div_ceil(BLOCK_SIZE) * BLOCK_SIZE; + let mut archive = Vec::with_capacity(BLOCK_SIZE + padded_contents + 2 * BLOCK_SIZE); + archive.extend_from_slice(&header); + archive.extend_from_slice(contents); + archive.resize(BLOCK_SIZE + padded_contents, 0); + archive.resize(BLOCK_SIZE + padded_contents + 2 * BLOCK_SIZE, 0); + archive.into() + } + + #[test] + fn runtime_parser_accepts_ustar() { + let archive = parse(ustar(b"runner.json", b"", br#"{"StartWasm":"file"}"#)).unwrap(); + assert_eq!( + archive.data.get("runner.json").unwrap().as_ref(), + br#"{"StartWasm":"file"}"# + ); + } + + #[test] + fn ustar_prefix_is_separated_from_name() { + let archive = + super::super::Archive::from_ustar(ustar(b"file", b"nested", b"value")).unwrap(); + assert_eq!( + archive.data.keys().map(String::as_str).collect::>(), + ["nested/file"], + "USTAR prefix and name fields must be joined with a slash" + ); + } + + #[test] + fn full_width_ustar_name_is_not_truncated() { + let name = [b'x'; 100]; + let archive = super::super::Archive::from_ustar(ustar(&name, b"", b"value")).unwrap(); + assert_eq!( + archive.data.keys().next().unwrap().len(), + name.len(), + "a full-width USTAR name field must retain its final byte" + ); + } +} diff --git a/runners/support/tools/genvm-floats-to-soft/tests/regressions.rs b/runners/support/tools/genvm-floats-to-soft/tests/regressions.rs new file mode 100644 index 00000000..ec26402d --- /dev/null +++ b/runners/support/tools/genvm-floats-to-soft/tests/regressions.rs @@ -0,0 +1,147 @@ +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use wasm_encoder::{ + CodeSection, EntityType, ExportKind, ExportSection, Function, FunctionSection, ImportSection, + Instruction, MemoryType, Module, TypeSection, ValType, +}; + +static NEXT_FILE_ID: AtomicU64 = AtomicU64::new(0); + +fn f32_add_module(import_memory: bool) -> Vec { + let mut module = Module::new(); + + let mut types = TypeSection::new(); + types.function([ValType::F32, ValType::F32], [ValType::F32]); + module.section(&types); + + if import_memory { + let mut imports = ImportSection::new(); + imports.import( + "env", + "memory", + EntityType::Memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }), + ); + module.section(&imports); + } + + let mut functions = FunctionSection::new(); + functions.function(0); + module.section(&functions); + + let mut exports = ExportSection::new(); + exports.export("add", ExportKind::Func, 0); + module.section(&exports); + + let mut function = Function::new([]); + function.instruction(&Instruction::LocalGet(0)); + function.instruction(&Instruction::LocalGet(1)); + function.instruction(&Instruction::F32Add); + function.instruction(&Instruction::End); + let mut code = CodeSection::new(); + code.function(&function); + module.section(&code); + + module.finish() +} + +fn rewrite(input: &[u8], case: &str) -> Vec { + let id = NEXT_FILE_ID.fetch_add(1, Ordering::Relaxed); + let prefix = format!("genvm-floats-to-soft-{case}-{}-{id}", std::process::id()); + let input_path = std::env::temp_dir().join(format!("{prefix}.wasm")); + let output_path = std::env::temp_dir().join(format!("{prefix}.rewritten.wasm")); + std::fs::write(&input_path, input).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_genvm-floats-to-soft")) + .arg(&input_path) + .arg(&output_path) + .output() + .unwrap(); + let rewritten = std::fs::read(&output_path).ok(); + + let _ = std::fs::remove_file(input_path); + let _ = std::fs::remove_file(output_path); + + assert!( + output.status.success(), + "rewriter rejected valid Wasm: {}", + String::from_utf8_lossy(&output.stderr) + ); + rewritten.expect("successful rewriter did not create its output") +} + +fn rewritten_indices(bytes: &[u8]) -> (u32, u32, u32) { + let mut function_import_count = 0; + let mut f32_add_index = None; + let mut add_export_index = None; + let mut rewritten_call_index = None; + + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + match payload.unwrap() { + wasmparser::Payload::ImportSection(section) => { + for import in section { + let import = import.unwrap(); + if matches!(import.ty, wasmparser::TypeRef::Func(_)) { + if import.module == "softfloat" && import.name == "f32_add" { + f32_add_index = Some(function_import_count); + } + function_import_count += 1; + } + } + } + wasmparser::Payload::ExportSection(section) => { + for export in section { + let export = export.unwrap(); + if export.name == "add" { + add_export_index = Some(export.index); + } + } + } + wasmparser::Payload::CodeSectionEntry(body) => { + let mut reader = body.get_operators_reader().unwrap(); + while !reader.eof() { + if let wasmparser::Operator::Call { function_index } = reader.read().unwrap() { + rewritten_call_index = Some(function_index); + } + } + } + _ => {} + } + } + + ( + f32_add_index.unwrap(), + add_export_index.unwrap(), + rewritten_call_index.unwrap(), + ) +} + +fn assert_f32_add_rewritten(input: Vec, case: &str) { + let rewritten = rewrite(&input, case); + let (f32_add_index, add_export_index, rewritten_call_index) = rewritten_indices(&rewritten); + + assert_eq!( + rewritten_call_index, f32_add_index, + "f32.add must call softfloat.f32_add" + ); + assert!( + add_export_index > f32_add_index, + "the export must still point to the local function" + ); +} + +#[test] +fn regression_rewrites_module_without_an_import_section() { + assert_f32_add_rewritten(f32_add_module(false), "no-import-section"); +} + +#[test] +fn regression_non_function_imports_do_not_shift_function_indices() { + assert_f32_add_rewritten(f32_add_module(true), "imported-memory"); +} From eec59abf625bab687e572cbfab244d7ee920267f Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 31 Jul 2026 12:10:20 +0100 Subject: [PATCH 2/4] test: expose additional runner regressions --- executor/src/runners/parse.rs | 17 +++- executor/src/wasi/preview1.rs | 78 +++++++++++++++++++ .../tests/test_calldata_corpus.py | 5 ++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/executor/src/runners/parse.rs b/executor/src/runners/parse.rs index 43745e46..e00e0b86 100644 --- a/executor/src/runners/parse.rs +++ b/executor/src/runners/parse.rs @@ -100,6 +100,10 @@ mod tests { const BLOCK_SIZE: usize = 512; fn ustar(name: &[u8], prefix: &[u8], contents: &[u8]) -> bytes::Bytes { + ustar_with_type(name, prefix, contents, b'0') + } + + fn ustar_with_type(name: &[u8], prefix: &[u8], contents: &[u8], type_flag: u8) -> bytes::Bytes { assert!(name.len() <= 100); assert!(prefix.len() <= 155); @@ -112,7 +116,7 @@ mod tests { header[124..136].copy_from_slice(size.as_bytes()); header[136..148].copy_from_slice(b"00000000000\0"); header[148..156].fill(b' '); - header[156] = b'0'; + header[156] = type_flag; header[257..265].copy_from_slice(b"ustar\x0000".as_slice()); header[345..345 + prefix.len()].copy_from_slice(prefix); let checksum: u32 = header.iter().map(|byte| *byte as u32).sum(); @@ -157,4 +161,15 @@ mod tests { "a full-width USTAR name field must retain its final byte" ); } + + #[test] + fn ustar_directory_type_is_not_exposed_as_a_file() { + let archive = + super::super::Archive::from_ustar(ustar_with_type(b"nested", b"", b"", b'5')).unwrap(); + assert!( + archive.data.is_empty(), + "USTAR type flag `5` denotes a directory; got entries {:?}", + archive.data.keys().collect::>() + ); + } } diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index 1845be62..e416a669 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1366,9 +1366,87 @@ fn write_bytes_capacity( #[cfg(test)] mod tests { + use super::*; use rand_core::RngCore as _; use sha3::Digest as _; + fn test_context() -> Context { + Context::new( + chrono::DateTime::from_timestamp(0, 0).unwrap(), + base::Config { + needs_error_fingerprint: false, + permissions: base::Permissions { + deterministic: true, + write_storage: false, + send_messages: false, + call_others: false, + spawn_nondet: false, + register_runners: false, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: crate::public_abi::StorageType::Default, + topmost_runner_id: crate::runners::Id::Custom { + hash: Bytes32Hash::ZERO, + }, + }, + }, + [0; 32], + ) + } + + #[test] + fn regression_parent_component_traverses_to_parent_directory() { + let mut context = test_context(); + context + .map_file("/parent/child/file", bytes::Bytes::from_static(b"value")) + .unwrap(); + let mut vfs = vfs::VFS::new(Vec::new(), rt::memlimiter::Limiter::new()).unwrap(); + let context_vfs = ContextVFS { + vfs: &mut vfs, + context: &mut context, + }; + + let FilesTrie::Dir { children } = context_vfs.context.fs.as_ref() else { + panic!("root must be a directory"); + }; + let parent = children.get("parent").unwrap(); + let FilesTrie::Dir { children } = parent.as_ref() else { + panic!("parent must be a directory"); + }; + let child = children.get("child").unwrap(); + let mut path = vec!["parent".to_owned(), "child".to_owned()]; + + let resolved = context_vfs + .dir_fd_get_trie("..", child, &mut Some(&mut path)) + .expect("`..` inside the preopened filesystem must resolve its parent"); + + assert!(std::ptr::eq(resolved, parent.as_ref())); + assert_eq!(path, ["parent"]); + } + + #[test] + fn regression_mapped_dot_component_is_reachable() { + let mut context = test_context(); + context + .map_file("/parent/./file", bytes::Bytes::from_static(b"value")) + .unwrap(); + + let FilesTrie::Dir { children } = context.fs.as_ref() else { + panic!("root must be a directory"); + }; + let parent = children.get("parent").unwrap(); + let FilesTrie::Dir { children } = parent.as_ref() else { + panic!("parent must be a directory"); + }; + + assert!( + children.contains_key("file"), + "mapped paths must normalize `.` like the WASI path resolver; got {:?}", + children.keys().collect::>() + ); + } + /// Known-answer test for the deterministic `random_get` byte layout. The seed is a /// fixed 32-byte value consumed as 8 little-endian `u32` words (matching /// `Context::new`), and `fill_bytes` produces the exact stream below. This pins the diff --git a/runners/genlayer-py-std/tests/test_calldata_corpus.py b/runners/genlayer-py-std/tests/test_calldata_corpus.py index 925992ec..7cc58fea 100644 --- a/runners/genlayer-py-std/tests/test_calldata_corpus.py +++ b/runners/genlayer-py-std/tests/test_calldata_corpus.py @@ -27,3 +27,8 @@ def test_calldata_corpus_decode_roundtrip(): assert calldata.decode(bytes.fromhex(expected)) == value, ( f'roundtrip mismatch for {value!r}' ) + + +def test_memoryview_roundtrip_as_bytes(): + value = memoryview(b'abc') + assert calldata.decode(calldata.encode(value)) == value.tobytes() From 78f392eee81a6fe59fc0108d88c3e7d5736b6c7b Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 31 Jul 2026 12:45:15 +0100 Subject: [PATCH 3/4] test: expose deeper runner regressions --- executor/src/runners/parse.rs | 42 +++++++++++++++ executor/src/wasi/preview1.rs | 51 +++++++++++++++++++ .../runner/softfloat_trunc_trap/prepare.py | 19 +++++++ .../runner/softfloat_trunc_trap/runner.json | 10 ++++ .../softfloat_trunc_trap.0.stdout | 1 + .../softfloat_trunc_trap.jsonnet | 9 ++++ .../softfloat_trunc_trap.wat | 12 +++++ 7 files changed, 144 insertions(+) create mode 100644 tests/integration/runner/softfloat_trunc_trap/prepare.py create mode 100644 tests/integration/runner/softfloat_trunc_trap/runner.json create mode 100644 tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.0.stdout create mode 100644 tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.jsonnet create mode 100644 tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.wat diff --git a/executor/src/runners/parse.rs b/executor/src/runners/parse.rs index e00e0b86..3461c0a4 100644 --- a/executor/src/runners/parse.rs +++ b/executor/src/runners/parse.rs @@ -96,6 +96,7 @@ fn code_to_archive_from_text(code: bytes::Bytes) -> rt::errors::Result>() ); } + + #[test] + fn ustar_rejects_a_header_with_a_bad_checksum() { + let mut archive = ustar(b"runner.json", b"", br#"{"StartWasm":"file"}"#).to_vec(); + archive[0] = b'R'; + + assert!( + super::super::Archive::from_ustar(archive.into()).is_err(), + "a USTAR header modified without updating its checksum must be rejected" + ); + } + + #[test] + fn zip_rejects_stored_contents_with_a_bad_crc() { + let contents = b"payload whose CRC must be checked"; + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + writer + .start_file( + "payload", + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored), + ) + .unwrap(); + writer.write_all(contents).unwrap(); + writer.finish().unwrap(); + } + + let mut archive = cursor.into_inner(); + let contents_offset = archive + .windows(contents.len()) + .position(|window| window == contents) + .unwrap(); + archive[contents_offset] ^= 1; + + assert!( + parse(archive.into()).is_err(), + "stored ZIP contents modified without updating their CRC must be rejected" + ); + } } diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index e416a669..0a1860e1 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1447,6 +1447,57 @@ mod tests { ); } + #[test] + fn directory_mapping_rejects_normalized_path_collisions() { + let archive = crate::runners::ArchiveCache::new( + symbol_table::GlobalSymbol::from("custom:path-collision"), + crate::runners::Archive { + data: BTreeMap::from([ + ( + "tree/a//file".to_owned(), + bytes::Bytes::from_static(b"first"), + ), + ( + "tree/a/file".to_owned(), + bytes::Bytes::from_static(b"second"), + ), + ]), + total_size: 11, + }, + ); + let limiter = rt::memlimiter::Limiter::new(); + let mut context = test_context(); + + crate::rt::supervisor::actions::map_archive_file( + &mut context, + &limiter, + &archive, + "tree/", + "/mapped/", + ) + .expect_err( + "distinct archive entries that normalize to one VFS path must not silently overwrite", + ); + } + + #[test] + fn mapping_a_file_over_a_directory_does_not_delete_the_subtree() { + let mut context = test_context(); + context + .map_file( + "/mapped/child", + bytes::Bytes::from_static(b"child contents"), + ) + .unwrap(); + + assert!( + context + .map_file("/mapped", bytes::Bytes::from_static(b"replacement")) + .is_err(), + "mapping a file over an existing directory must not silently delete its children" + ); + } + /// Known-answer test for the deterministic `random_get` byte layout. The seed is a /// fixed 32-byte value consumed as 8 little-endian `u32` words (matching /// `Context::new`), and `fill_bytes` produces the exact stream below. This pins the diff --git a/tests/integration/runner/softfloat_trunc_trap/prepare.py b/tests/integration/runner/softfloat_trunc_trap/prepare.py new file mode 100644 index 00000000..2263d82d --- /dev/null +++ b/tests/integration/runner/softfloat_trunc_trap/prepare.py @@ -0,0 +1,19 @@ +import subprocess +import zipfile +from pathlib import Path + +root = Path(__file__).parent + +subprocess.run( + [ + 'wat2wasm', + str(root / 'softfloat_trunc_trap.wat'), + '-o', + str(root / 'softfloat_trunc_trap.wasm'), + ], + check=True, +) + +with zipfile.ZipFile(root / 'contract.zip', 'w') as archive: + for name in ['runner.json', 'softfloat_trunc_trap.wasm']: + archive.write(root / name, name) diff --git a/tests/integration/runner/softfloat_trunc_trap/runner.json b/tests/integration/runner/softfloat_trunc_trap/runner.json new file mode 100644 index 00000000..725954d3 --- /dev/null +++ b/tests/integration/runner/softfloat_trunc_trap/runner.json @@ -0,0 +1,10 @@ +{ + "Seq": [ + { + "Depends": "softfloat:test" + }, + { + "StartWasm": "softfloat_trunc_trap.wasm" + } + ] +} diff --git a/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.0.stdout b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.0.stdout new file mode 100644 index 00000000..6917b0fc --- /dev/null +++ b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.0.stdout @@ -0,0 +1 @@ +executed with `VMError("wasm_trap unreachable")` diff --git a/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.jsonnet b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.jsonnet new file mode 100644 index 00000000..850737ae --- /dev/null +++ b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.jsonnet @@ -0,0 +1,9 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +{ + tags: util.features([['runner', 'softfloat']], 'stable'), + prepare: '${jsonnetDir}/prepare.py', + entry: util.addPaths([ + simple.run('${jsonnetDir}/contract.zip') {stable_hash: false}, + ]), +} diff --git a/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.wat b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.wat new file mode 100644 index 00000000..527a2a75 --- /dev/null +++ b/tests/integration/runner/softfloat_trunc_trap/softfloat_trunc_trap.wat @@ -0,0 +1,12 @@ +(module + (import "softfloat" "f32_to_i32_trunc" (func $f32_to_i32_trunc (param f32) (result i32))) + + (func (export "_start") + f32.const nan + call $f32_to_i32_trunc + drop + ) + + (memory $mem 1) + (export "memory" (memory $mem)) +) From 0f9e31e9cf22d7980685dbfc72ef82db64c01cac Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 31 Jul 2026 13:56:53 +0100 Subject: [PATCH 4/4] test: expose runner metadata regressions --- executor/src/runners/actions.rs | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/executor/src/runners/actions.rs b/executor/src/runners/actions.rs index 83221e64..4e7f5224 100644 --- a/executor/src/runners/actions.rs +++ b/executor/src/runners/actions.rs @@ -33,3 +33,41 @@ pub enum InitAction { action: Box, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runner_action_accepts_schema_annotation() { + let parsed = serde_json::from_str::( + r#"{ + "$schema": "https://raw.githubusercontent.com/genlayerlabs/genvm/refs/heads/main/doc/schemas/runner.json", + "StartWasm": "file" + }"#, + ); + + assert!( + matches!(parsed, Ok(InitAction::StartWasm(ref path)) if path.as_ref() == "file"), + "runner.json permits the optional `$schema` annotation; got {parsed:?}" + ); + } + + #[test] + fn runner_action_rejects_unknown_payload_fields() { + let parsed = serde_json::from_str::( + r#"{ + "MapFile": { + "file": "contract.py", + "to": "/contract.py", + "destination": "/silently-ignored.py" + } + }"#, + ); + + assert!( + parsed.is_err(), + "unknown runner action fields must be rejected instead of silently ignored; got {parsed:?}" + ); + } +}