Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions executor/src/caching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
);
}
}
117 changes: 117 additions & 0 deletions executor/src/exe/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&registry).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(&registry).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"
);
}
}
164 changes: 164 additions & 0 deletions executor/src/exe/precompile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
63 changes: 63 additions & 0 deletions executor/src/rt/supervisor/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(&registry, &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(&registry, &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
Expand Down
Loading