diff --git a/executor/src/caching.rs b/executor/src/caching.rs index 1b4a12ce8..4b1db31c7 100644 --- a/executor/src/caching.rs +++ b/executor/src/caching.rs @@ -42,3 +42,33 @@ pub fn path_in_zip_to_hash(path: &str) -> String { base32::encode(base32::Alphabet::Rfc4648 { padding: false }, digits) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + #[test] + fn native_module_cache_rejects_group_or_world_writable_directory() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "genvm-untrusted-cache-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); + let path = root.to_string_lossy(); + + let precompile_cache_accepted = get_cache_dir(&path).is_ok(); + let runtime_cache_accepted = crate::runners::cache::get_cache_dir(&path).is_ok(); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + !precompile_cache_accepted && !runtime_cache_accepted, + "the precompile and runtime cache entry points must reject group/world-writable directories before files from them reach unsafe Wasmtime deserialization (precompile accepted: {precompile_cache_accepted}, runtime accepted: {runtime_cache_accepted})" + ); + } +} diff --git a/executor/src/exe/check.rs b/executor/src/exe/check.rs index e9d3da257..a8d4885f0 100644 --- a/executor/src/exe/check.rs +++ b/executor/src/exe/check.rs @@ -72,3 +72,120 @@ pub fn handle(args: Args, config: config::Config) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn bucket() -> config::FeesBucketConfig { + config::FeesBucketConfig { + bucket_no: vec![0], + subtract_on_start_expr: "0".to_owned(), + delta_expr: "0".to_owned(), + } + } + + fn test_root(label: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "genvm-check-{label}-{}-{unique}", + std::process::id() + )) + } + + fn test_config(root: &std::path::Path) -> config::Config { + config::Config { + modules: config::Modules { + llm: config::Module { + address: String::new(), + }, + web: config::Module { + address: String::new(), + }, + }, + fees: config::FeesConfig { + expr_prelude: String::new(), + storage: bucket(), + message_receipt: bucket(), + nondet_output: bucket(), + message_fee: bucket(), + event: bucket(), + }, + cache_dir: root.join("cache").to_string_lossy().into_owned(), + runners_dir: root.join("runners").to_string_lossy().into_owned(), + registry_dir: root.join("registry").to_string_lossy().into_owned(), + base: genvm_common::BaseConfig { + threads: 1, + blocking_threads: 1, + log_level: genvm_common::logger::Level::Info, + log_disable: String::new(), + }, + } + } + + #[test] + fn check_rejects_short_registry_hash_without_panicking() { + let root = test_root("short-runner-hash"); + let registry = root.join("registry"); + let runners = root.join("runners"); + std::fs::create_dir_all(®istry).unwrap(); + std::fs::create_dir_all(&runners).unwrap(); + std::fs::write(registry.join("all.json"), r#"{"runner":["x"]}"#).unwrap(); + std::fs::write(registry.join("latest.json"), "{}").unwrap(); + + let config = test_config(&root); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + handle(Args { precompile: false }, config) + })); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + result.is_ok(), + "genvm check must return an installation error instead of panicking on a short registry hash" + ); + assert!( + result.unwrap().is_err(), + "genvm check must reject a malformed registry hash" + ); + } + + #[test] + fn check_rejects_runner_name_that_escapes_runners_directory() { + let root = test_root("runner-name-traversal"); + let registry = root.join("registry"); + let runners = root.join("runners"); + std::fs::create_dir_all(®istry).unwrap(); + std::fs::create_dir_all(&runners).unwrap(); + + let contents = b"outside runner artifact"; + use sha2::Digest as _; + let digest: [u8; 32] = sha2::Sha256::digest(contents).into(); + let hash = genvm_common::Bytes32Hash::from_bytes(digest).to_gvm32(); + let mut escaped_path = root.join("outside"); + escaped_path.push(&hash[..2]); + escaped_path.push(&hash[2..]); + escaped_path.set_extension("tar"); + std::fs::create_dir_all(escaped_path.parent().unwrap()).unwrap(); + std::fs::write(&escaped_path, contents).unwrap(); + + let all = serde_json::to_vec(&std::collections::BTreeMap::from([( + "../outside", + vec![hash], + )])) + .unwrap(); + std::fs::write(registry.join("all.json"), all).unwrap(); + std::fs::write(registry.join("latest.json"), "{}").unwrap(); + + let result = handle(Args { precompile: false }, test_config(&root)); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + result.is_err(), + "genvm check must reject a registry runner name that traverses outside runners_dir" + ); + } +} diff --git a/executor/src/exe/precompile.rs b/executor/src/exe/precompile.rs index ac4e83212..272220719 100644 --- a/executor/src/exe/precompile.rs +++ b/executor/src/exe/precompile.rs @@ -167,3 +167,167 @@ pub fn run(config: &config::Config) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_root(label: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "genvm-precompile-{label}-{}-{unique}", + std::process::id() + )) + } + + fn write_ustar(path: &std::path::Path, entry_name: &str, contents: &[u8]) { + let mut header = [0_u8; 512]; + header[..entry_name.len()].copy_from_slice(entry_name.as_bytes()); + 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"); + header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).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\0\x30\x30"); + let checksum: u32 = header.iter().map(|byte| u32::from(*byte)).sum(); + header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes()); + + let padded_contents = contents.len().next_multiple_of(512); + let mut archive = Vec::with_capacity(512 + padded_contents + 1024); + archive.extend_from_slice(&header); + archive.extend_from_slice(contents); + archive.resize(512 + padded_contents + 1024, 0); + + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, archive).unwrap(); + } + + #[test] + fn failed_recompile_does_not_leave_previous_module_loadable() { + let root = test_root("failed-recompile"); + let result_path = root.join("module.det"); + let engines = genvm::rt::supervisor::create_engines(|_| Ok(())).unwrap(); + let valid_wasm = b"\0asm\x01\0\0\0"; + + compile_single_file_single_mode( + &result_path, + &engines.det, + valid_wasm, + "det", + std::path::Path::new("runner.tar"), + "module.wasm", + ) + .unwrap(); + + // This retains the Wasm magic/version but ends in an incomplete section, + // so cold compilation rejects it after recognizing it as core Wasm. + let invalid_wasm = b"\0asm\x01\0\0\0\x01"; + let recompile = compile_single_file_single_mode( + &result_path, + &engines.det, + invalid_wasm, + "det", + std::path::Path::new("runner.tar"), + "module.wasm", + ); + let malformed_replacement_rejected = recompile.is_err(); + + // SAFETY: this file is the exact, unmodified output produced above by + // the same Wasmtime engine; the failed replacement never writes it. + let stale_module_still_loads = + unsafe { wasmtime::Module::deserialize_file(&engines.det, &result_path) }.is_ok(); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + malformed_replacement_rejected, + "the malformed replacement must fail cold compilation" + ); + assert!( + !stale_module_still_loads, + "a failed recompile must invalidate the previous module at the same cache key; otherwise cached loading accepts code that cold compilation rejects" + ); + } + + #[test] + fn successful_recompile_does_not_keep_module_for_non_wasm_source() { + let root = test_root("non-wasm-replacement"); + let runners_dir = root.join("runners"); + let precompile_dir = root.join("pc"); + let runner_path = runners_dir.join("runner/hash.tar"); + let entry_name = "module.wasm"; + let engines = genvm::rt::supervisor::create_engines(|_| Ok(())).unwrap(); + + write_ustar(&runner_path, entry_name, b"\0asm\x01\0\0\0"); + compile_single_file(&precompile_dir, &engines, &runners_dir, &runner_path).unwrap(); + + let module_key = caching::path_in_zip_to_hash(entry_name); + let cache_path = precompile_dir.join("runner/hash").join(module_key); + let det_path = cache_path.with_extension(caching::DET_NON_DET_PRECOMPILED_SUFFIX.det); + let non_det_path = + cache_path.with_extension(caching::DET_NON_DET_PRECOMPILED_SUFFIX.non_det); + assert!(det_path.exists()); + assert!(non_det_path.exists()); + + write_ustar(&runner_path, entry_name, b"this is no longer wasm"); + compile_single_file(&precompile_dir, &engines, &runners_dir, &runner_path).unwrap(); + + let stale_pair_still_exists = det_path.exists() || non_det_path.exists(); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + !stale_pair_still_exists, + "a successful precompile pass must remove cached modules for a source that is no longer Wasm; otherwise cached execution accepts bytes that cold execution rejects" + ); + } + + #[cfg(unix)] + #[test] + fn recompile_replaces_precompiled_file_without_mutating_live_inode() { + use std::os::unix::fs::MetadataExt as _; + + let root = test_root("atomic-replacement"); + let result_path = root.join("module.det"); + let engines = genvm::rt::supervisor::create_engines(|_| Ok(())).unwrap(); + let empty_module = b"\0asm\x01\0\0\0"; + let module_returning_one = b"\0asm\x01\0\0\0\x01\x05\x01\x60\0\x01\x7f\x03\x02\x01\0\x07\x09\x01\x05value\0\0\x0a\x06\x01\x04\0\x41\x01\x0b"; + + compile_single_file_single_mode( + &result_path, + &engines.det, + empty_module, + "det", + std::path::Path::new("runner.tar"), + "module.wasm", + ) + .unwrap(); + let first_inode = std::fs::metadata(&result_path).unwrap().ino(); + let first_artifact = std::fs::read(&result_path).unwrap(); + + compile_single_file_single_mode( + &result_path, + &engines.det, + module_returning_one, + "det", + std::path::Path::new("runner.tar"), + "module.wasm", + ) + .unwrap(); + let second_inode = std::fs::metadata(&result_path).unwrap().ino(); + let second_artifact = std::fs::read(&result_path).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + + assert_ne!( + first_artifact, second_artifact, + "test modules must produce different artifacts" + ); + assert_ne!( + first_inode, second_inode, + "recompilation must atomically replace the cache file instead of modifying the inode that a live Wasmtime module may still have memory-mapped" + ); + } +} diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 597a5bffb..c263b2440 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -1209,6 +1209,26 @@ mod tests { items.iter().map(|item| (*item).to_owned()).collect() } + #[test] + fn mapping_target_rejects_empty_destination() { + let target = ""; + + assert!( + check_mapping_target(target).is_err(), + "MapFile must reject an empty destination instead of creating an unreachable empty-name entry" + ); + } + + #[test] + fn mapping_target_cannot_hide_vm_behind_dot_component() { + let target = "/./vm/secret"; + + assert!( + check_mapping_target(target).is_err(), + "MapFile destination {target:?} normalizes into the protected /vm tree and must be rejected" + ); + } + #[test] fn bounded_contexts_collapse_middle_at_limit() { let mut got = VecDeque::new(); @@ -1607,6 +1627,49 @@ mod tests { ); } + #[tokio::test] + async fn register_rejects_malformed_runner_actions() { + let registry = runners::cache::WeakCache::new(); + let code = bytes::Bytes::from_static(b"# { \"UnknownAction\": true }\n"); + let limiter = rt::memlimiter::Limiter::new(); + let mut loaded = runners::cache::LoadedSet::default(); + + let result = + register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()).await; + + assert!( + result.is_err(), + "RegisterRunner must not return an ID for a runner whose runner.json cannot be deserialized; got {result:?}" + ); + assert!( + !loaded.contains(custom_id_of(&code)), + "a runner with malformed actions must not become resolvable" + ); + } + + #[tokio::test] + async fn register_rejects_runner_from_incompatible_major() { + let registry = runners::cache::WeakCache::new(); + let incompatible_major = genvm_common::version::CURRENT.major.checked_add(1).unwrap(); + let code = bytes::Bytes::from(format!( + "# v{incompatible_major}.0.0\n# {{ \"StartWasm\": \"file\" }}\n" + )); + let limiter = rt::memlimiter::Limiter::new(); + let mut loaded = runners::cache::LoadedSet::default(); + + let result = + register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()).await; + + assert!( + result.is_err(), + "RegisterRunner must reject a runner declaring incompatible major {incompatible_major}; got {result:?}" + ); + assert!( + !loaded.contains(custom_id_of(&code)), + "an incompatible runner must not become resolvable" + ); + } + /// Grant transport: the pins handed to a child at `RunNondet`/ /// `Sandbox` call time keep the content alive even after the granting parent /// dies -- a queued nondet validator task must still find it and load it into diff --git a/executor/src/runners/parse.rs b/executor/src/runners/parse.rs index 3461c0a44..9404c2c3d 100644 --- a/executor/src/runners/parse.rs +++ b/executor/src/runners/parse.rs @@ -163,6 +163,18 @@ mod tests { ); } + #[test] + fn ustar_name_stops_at_nul_terminator() { + let archive = + super::super::Archive::from_ustar(ustar(b"file\0ignored", b"", b"value")).unwrap(); + + assert_eq!( + archive.data.keys().map(String::as_str).collect::>(), + ["file"], + "USTAR names must stop at the first NUL in the fixed-width name field" + ); + } + #[test] fn ustar_directory_type_is_not_exposed_as_a_file() { let archive = @@ -185,6 +197,17 @@ mod tests { ); } + #[test] + fn ustar_rejects_missing_end_markers() { + let mut archive = ustar(b"runner.json", b"", br#"{"StartWasm":"file"}"#).to_vec(); + archive.truncate(archive.len() - 2 * BLOCK_SIZE); + + assert!( + super::super::Archive::from_ustar(archive.into()).is_err(), + "a truncated USTAR archive without its two zero end markers must be rejected" + ); + } + #[test] fn zip_rejects_stored_contents_with_a_bad_crc() { let contents = b"payload whose CRC must be checked"; @@ -214,4 +237,67 @@ mod tests { "stored ZIP contents modified without updating their CRC must be rejected" ); } + + #[test] + fn zip_rejects_stored_entry_with_mismatched_sizes() { + let contents = b"stored payload"; + 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 central_header = archive + .windows(4) + .position(|window| window == b"PK\x01\x02") + .unwrap(); + let uncompressed_size = central_header + 24; + archive[uncompressed_size..uncompressed_size + 4] + .copy_from_slice(&((contents.len() as u32) + 1).to_le_bytes()); + + assert!( + parse(archive.into()).is_err(), + "a stored ZIP entry must not claim different compressed and uncompressed sizes" + ); + } + + #[test] + fn zip_rejects_local_and_central_compression_mismatch() { + let contents = b"payload that is compressed in the local entry"; + 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::Deflated), + ) + .unwrap(); + writer.write_all(contents).unwrap(); + writer.finish().unwrap(); + } + + let mut archive = cursor.into_inner(); + let central_header = archive + .windows(4) + .position(|window| window == b"PK\x01\x02") + .unwrap(); + let central_compression = central_header + 10; + archive[central_compression..central_compression + 2].copy_from_slice(&0_u16.to_le_bytes()); + + assert!( + parse(archive.into()).is_err(), + "a Deflated local entry must not bypass the Stored-only policy through conflicting central metadata" + ); + } } diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index 0a1860e18..420481ba9 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1395,6 +1395,41 @@ mod tests { ) } + #[test] + fn set_args_rejects_embedded_nul() { + let mut context = test_context(); + let args = vec!["visible\0hidden".to_owned()]; + + assert!( + context.set_args(&args).is_err(), + "an embedded NUL must not create an argument whose WASI buffer terminates early" + ); + } + + #[test] + fn set_env_rejects_invalid_entries() { + let invalid = [ + ("BAD=NAME", "value"), + ("BAD\0NAME", "value"), + ("GOOD", "visible\0hidden"), + ]; + let accepted = invalid + .iter() + .filter_map(|(name, value)| { + let mut context = test_context(); + context + .set_env(&[(name.to_string(), value.to_string())]) + .is_ok() + .then_some(format!("{name:?}={value:?}")) + }) + .collect::>(); + + assert!( + accepted.is_empty(), + "environment entries that break WASI name/value framing must be rejected; accepted: {accepted:?}" + ); + } + #[test] fn regression_parent_component_traverses_to_parent_directory() { let mut context = test_context(); @@ -1480,6 +1515,33 @@ mod tests { ); } + #[test] + fn mapping_missing_archive_directory_is_rejected() { + let archive = crate::runners::ArchiveCache::new( + symbol_table::GlobalSymbol::from("custom:missing-directory"), + crate::runners::Archive { + data: BTreeMap::from([( + "present/file".to_owned(), + bytes::Bytes::from_static(b"contents"), + )]), + total_size: 8, + }, + ); + let limiter = rt::memlimiter::Limiter::new(); + let mut context = test_context(); + + crate::rt::supervisor::actions::map_archive_file( + &mut context, + &limiter, + &archive, + "missing/", + "/mapped/", + ) + .expect_err( + "mapping a nonexistent directory must fail like mapping a nonexistent single file", + ); + } + #[test] fn mapping_a_file_over_a_directory_does_not_delete_the_subtree() { let mut context = test_context(); diff --git a/tests/integration/runner/self_dependency/.gitignore b/tests/integration/runner/self_dependency/.gitignore new file mode 100644 index 000000000..843c59cbc --- /dev/null +++ b/tests/integration/runner/self_dependency/.gitignore @@ -0,0 +1 @@ +/contract.zip diff --git a/tests/integration/runner/self_dependency/prepare.py b/tests/integration/runner/self_dependency/prepare.py new file mode 100644 index 000000000..94a948b4b --- /dev/null +++ b/tests/integration/runner/self_dependency/prepare.py @@ -0,0 +1,19 @@ +import subprocess +import zipfile +from pathlib import Path + +root = Path(__file__).parent + +subprocess.run( + [ + 'wat2wasm', + str(root / 'self_dependency.wat'), + '-o', + str(root / 'self_dependency.wasm'), + ], + check=True, +) + +with zipfile.ZipFile(root / 'contract.zip', 'w') as archive: + for name in ['runner.json', 'self_dependency.wasm']: + archive.write(root / name, name) diff --git a/tests/integration/runner/self_dependency/runner.json b/tests/integration/runner/self_dependency/runner.json new file mode 100644 index 000000000..80f182141 --- /dev/null +++ b/tests/integration/runner/self_dependency/runner.json @@ -0,0 +1,10 @@ +{ + "Seq": [ + { + "Depends": "contract" + }, + { + "StartWasm": "self_dependency.wasm" + } + ] +} diff --git a/tests/integration/runner/self_dependency/self_dependency.0.stdout b/tests/integration/runner/self_dependency/self_dependency.0.stdout new file mode 100644 index 000000000..7d73027c8 --- /dev/null +++ b/tests/integration/runner/self_dependency/self_dependency.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/runner/self_dependency/self_dependency.jsonnet b/tests/integration/runner/self_dependency/self_dependency.jsonnet new file mode 100644 index 000000000..eaa9a0705 --- /dev/null +++ b/tests/integration/runner/self_dependency/self_dependency.jsonnet @@ -0,0 +1,16 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +{ + tags: util.features([['runner', 'dependency']], 'stable'), + prepare: '${jsonnetDir}/prepare.py', + entry: util.addPaths([ + simple.run('${jsonnetDir}/contract.zip') { + stable_hash: false, + runner_load_asserts: [ + {match: {}, count: 1}, + {match: {status: 'charged'}, count: 1}, + {match: {status: 'cached'}, count: 0}, + ], + }, + ]), +} diff --git a/tests/integration/runner/self_dependency/self_dependency.wat b/tests/integration/runner/self_dependency/self_dependency.wat new file mode 100644 index 000000000..6dfd1bf13 --- /dev/null +++ b/tests/integration/runner/self_dependency/self_dependency.wat @@ -0,0 +1,6 @@ +(module + (func (export "_start")) + + (memory $mem 1) + (export "memory" (memory $mem)) +)