From 8c1e26566c50a92cb1cc375849ffb30b3574c039 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Fri, 6 Mar 2026 15:04:04 -0500 Subject: [PATCH 1/8] Allow reproducible builds with javy. --- Cargo.lock | 2 +- crates/cli/src/commands.rs | 14 +++++++ crates/cli/src/main.rs | 1 + crates/codegen/CHANGELOG.md | 6 +++ crates/codegen/Cargo.toml | 2 +- crates/codegen/src/lib.rs | 52 +++++++++++++++++++++--- crates/codegen/tests/integration_test.rs | 31 ++++++++++++++ crates/plugin-processing/src/lib.rs | 33 ++++++++++++++- 8 files changed, 132 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec6957a0c..19d92bd16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "javy-codegen" -version = "4.0.0-alpha.1" +version = "4.0.0-alpha.2" dependencies = [ "anyhow", "brotli", diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index de6f8d353..5209e57f5 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -151,6 +151,7 @@ pub struct CodegenOptionGroup { pub wit: WitOptions, pub source: Source, pub plugin: Option, + pub deterministic: bool, } impl Default for CodegenOptionGroup { @@ -160,6 +161,7 @@ impl Default for CodegenOptionGroup { wit: WitOptions::default(), source: Source::Compressed, plugin: None, + deterministic: true, } } } @@ -186,6 +188,10 @@ option_group! { /// linked modules. JavaScript config options are also not supported when /// using this parameter. Plugin(PathBuf), + /// Produce deterministic output by using fixed clocks during + /// pre-initialization. Ensures identical input always produces + /// identical output. + Deterministic(bool), } } @@ -202,6 +208,7 @@ impl TryFrom>> for CodegenOptionGroup { let mut wit_world_specified = false; let mut source_specified = false; let mut plugin_specified = false; + let mut deterministic_specified = false; for option in value.iter().flat_map(|i| i.0.iter()) { match option { @@ -240,6 +247,13 @@ impl TryFrom>> for CodegenOptionGroup { options.plugin = Some(path.clone()); plugin_specified = true; } + CodegenOption::Deterministic(enabled) => { + if deterministic_specified { + bail!("deterministic can only be specified once"); + } + options.deterministic = *enabled; + deterministic_specified = true; + } } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 2fb277ed6..9cf35bb81 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -48,6 +48,7 @@ async fn main() -> Result<()> { generator.source_embedding(source_embedding); set_producer_version(&mut generator); + generator.deterministic(codegen_opts.deterministic); if codegen_opts.dynamic { generator.linking(LinkingKind::Dynamic); diff --git a/crates/codegen/CHANGELOG.md b/crates/codegen/CHANGELOG.md index fc49855af..3215f027f 100644 --- a/crates/codegen/CHANGELOG.md +++ b/crates/codegen/CHANGELOG.md @@ -12,6 +12,12 @@ Versioning](https://semver.org/spec/v2.0.0.html). - The `generate` method on `Generator` is now async. +### Added + +- `Generator::deterministic()` and CLI `-C deterministic` option for reproducible + static-linked Wasm builds. When enabled, fixed clocks are used during Wizer + pre-initialization to avoid timestamp-induced non-determinism. + ## [3.0.0] - 2025-11-12 ### Changed diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index 8768efbdc..a25eea97d 100644 --- a/crates/codegen/Cargo.toml +++ b/crates/codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "javy-codegen" -version = "4.0.0-alpha.1" +version = "4.0.0-alpha.2" authors.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 39f6789af..ada586d92 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -15,7 +15,9 @@ //! a particular version of the QuickJS engine compiled to Wasm. //! //! The generated Wasm module is self contained and the bytecode version matches -//! the exact requirements of the embedded QuickJs engine. +//! the exact requirements of the embedded QuickJs engine. Use +//! [`Generator::deterministic`] for reproducible builds (e.g., for verification +//! or caching). //! //! ## Dynamic code generation //! @@ -72,6 +74,7 @@ //! notice. use std::fs; +use std::time::Duration; pub(crate) mod bytecode; pub(crate) mod exports; @@ -92,11 +95,35 @@ use walrus::{ }; use wasm_opt::{OptimizationOptions, ShrinkLevel}; use wasmtime::{Engine, Linker, Store}; -use wasmtime_wasi::{WasiCtxBuilder, p2::pipe::MemoryInputPipe}; +use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder, p2::pipe::MemoryInputPipe}; use anyhow::Result; use wasmtime_wizer::Wizer; +struct FixedWallClock; + +impl HostWallClock for FixedWallClock { + fn resolution(&self) -> Duration { + Duration::from_secs(1) + } + + fn now(&self) -> Duration { + Duration::ZERO + } +} + +struct FixedMonotonicClock; + +impl HostMonotonicClock for FixedMonotonicClock { + fn resolution(&self) -> u64 { + 1_000_000_000 + } + + fn now(&self) -> u64 { + 0 + } +} + /// The kind of linking to use. #[derive(Debug, Clone, Default)] pub enum LinkingKind { @@ -174,6 +201,8 @@ pub struct Generator { js_runtime_config: Vec, /// The version string to include in the producers custom section. producer_version: Option, + /// Whether to use fixed clocks for deterministic builds. + deterministic: bool, } impl Generator { @@ -215,6 +244,13 @@ impl Generator { self.producer_version = Some(producer_version); self } + + /// Enable deterministic builds by using fixed clocks during Wizer + /// pre-initialization, ensuring identical output for identical input. + pub fn deterministic(&mut self, deterministic: bool) -> &mut Self { + self.deterministic = deterministic; + self + } } impl Generator { @@ -224,11 +260,17 @@ impl Generator { let module = match &self.linking { LinkingKind::Static => { let engine = Engine::default(); - let wasi = WasiCtxBuilder::new() + let mut builder = WasiCtxBuilder::new(); + builder .stdin(MemoryInputPipe::new(self.js_runtime_config.clone())) .inherit_stdout() - .inherit_stderr() - .build_p1(); + .inherit_stderr(); + if self.deterministic { + builder + .wall_clock(FixedWallClock) + .monotonic_clock(FixedMonotonicClock); + } + let wasi = builder.build_p1(); let mut store = Store::new(&engine, wasi); let wasm = Wizer::new() .init_func("initialize-runtime") diff --git a/crates/codegen/tests/integration_test.rs b/crates/codegen/tests/integration_test.rs index 858fe5cff..056ccf0ee 100644 --- a/crates/codegen/tests/integration_test.rs +++ b/crates/codegen/tests/integration_test.rs @@ -44,6 +44,37 @@ async fn test_snapshot_for_dynamically_linked_module() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_deterministic_builds_produce_identical_output() -> Result<()> { + let js = JS::from_file( + &cargo_manifest_dir() + .join("tests") + .join("sample-scripts") + .join("empty.js"), + )?; + + let generate = || async { + let mut generator = Generator::new(default_plugin()?); + generator.linking(LinkingKind::Static).deterministic(true); + generator.generate(&js).await + }; + + let first = generate().await?; + let second = generate().await?; + let third = generate().await?; + + assert_eq!( + first, second, + "deterministic builds should produce identical output across runs" + ); + assert_eq!( + second, third, + "deterministic builds should produce identical output across runs" + ); + + Ok(()) +} + fn cargo_manifest_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } diff --git a/crates/plugin-processing/src/lib.rs b/crates/plugin-processing/src/lib.rs index 58180be47..85153a425 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -1,11 +1,36 @@ use anyhow::{Result, bail}; +use std::time::Duration; use std::{borrow::Cow, fs}; use walrus::{FunctionId, ImportKind, ValType}; use wasmparser::{Parser, Payload}; use wasmtime::{Engine, Linker, Store}; -use wasmtime_wasi::WasiCtxBuilder; +use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; use wasmtime_wizer::Wizer; +struct FixedWallClock; + +impl HostWallClock for FixedWallClock { + fn resolution(&self) -> Duration { + Duration::from_secs(1) + } + + fn now(&self) -> Duration { + Duration::ZERO + } +} + +struct FixedMonotonicClock; + +impl HostMonotonicClock for FixedMonotonicClock { + fn resolution(&self) -> u64 { + 1_000_000_000 + } + + fn now(&self) -> u64 { + 0 + } +} + /// Extract core module if it's a component, then run wasm-opt and Wizer to /// initialize a plugin. pub async fn initialize_plugin(wasm_bytes: &[u8]) -> Result> { @@ -127,7 +152,11 @@ fn optimize_module(wasm_bytes: &[u8]) -> Result> { async fn preinitialize_module(wasm_bytes: &[u8]) -> Result> { let engine = Engine::default(); - let wasi = WasiCtxBuilder::new().inherit_stderr().build_p1(); + let wasi = WasiCtxBuilder::new() + .inherit_stderr() + .wall_clock(FixedWallClock) + .monotonic_clock(FixedMonotonicClock) + .build_p1(); let mut store = Store::new(&engine, wasi); Ok(Wizer::new() From b8a8490845645776a179a8419f67b5ce8a13b2a5 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Tue, 10 Mar 2026 10:57:56 -0400 Subject: [PATCH 2/8] Remove code duplication and add the determinism flag to the CLI as well --- Cargo.lock | 1 + crates/cli/src/commands.rs | 5 +++- crates/cli/src/main.rs | 6 +++- crates/cli/src/plugin.rs | 12 ++++++-- crates/codegen/Cargo.toml | 1 + crates/codegen/src/lib.rs | 31 ++------------------- crates/plugin-processing/src/lib.rs | 41 ++++++++++++++++++++++------ crates/plugin-processing/src/main.rs | 10 ++++++- 8 files changed, 65 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19d92bd16..f83072460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1661,6 +1661,7 @@ dependencies = [ "brotli", "convert_case", "insta", + "javy-plugin-processing", "swc_core", "tempfile", "tokio", diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 5209e57f5..28373e495 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -83,6 +83,9 @@ pub struct InitPluginCommandOpts { #[arg(short, long = "out")] /// Output path for the initialized plugin binary (default is stdout). pub out: Option, + #[arg(long)] + /// Use fixed clocks for deterministic output. + pub deterministic: bool, } impl ValueParserFactory for GroupOption @@ -161,7 +164,7 @@ impl Default for CodegenOptionGroup { wit: WitOptions::default(), source: Source::Compressed, plugin: None, - deterministic: true, + deterministic: false, } } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9cf35bb81..30f88f90b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -63,9 +63,13 @@ async fn main() -> Result<()> { } Command::InitPlugin(opts) => { let plugin_bytes = fs::read(&opts.plugin)?; + let config = javy_plugin_processing::PluginConfig { + deterministic: opts.deterministic, + }; let uninitialized_plugin = UninitializedPlugin::new(&plugin_bytes)?; - let initialized_plugin_bytes = uninitialized_plugin.initialize().await?; + let initialized_plugin_bytes = + uninitialized_plugin.initialize_with_config(&config).await?; let mut out: Box = match opts.out.as_ref() { Some(path) => Box::new(File::create(path)?), diff --git a/crates/cli/src/plugin.rs b/crates/cli/src/plugin.rs index 5fb071042..f728e467b 100644 --- a/crates/cli/src/plugin.rs +++ b/crates/cli/src/plugin.rs @@ -47,9 +47,17 @@ impl<'a> UninitializedPlugin<'a> { Ok(Self { bytes }) } - /// Initializes the plugin. + /// Initializes the plugin with the provided configuration. + pub(crate) async fn initialize_with_config( + &self, + config: &javy_plugin_processing::PluginConfig, + ) -> Result> { + javy_plugin_processing::initialize_plugin_with_config(self.bytes, config).await + } + + /// Initializes the plugin with default (non-deterministic) configuration. pub(crate) async fn initialize(&self) -> Result> { - javy_plugin_processing::initialize_plugin(self.bytes).await + self.initialize_with_config(&Default::default()).await } fn validate(plugin_bytes: &'a [u8]) -> Result<()> { diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index a25eea97d..797438e81 100644 --- a/crates/codegen/Cargo.toml +++ b/crates/codegen/Cargo.toml @@ -15,6 +15,7 @@ plugin_internal = [] [dependencies] anyhow = { workspace = true } brotli = { workspace = true } +javy-plugin-processing = { path = "../plugin-processing" } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wizer = { workspace = true, features = ["wasmtime"] } diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index ada586d92..5e4258c99 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -74,7 +74,6 @@ //! notice. use std::fs; -use std::time::Duration; pub(crate) mod bytecode; pub(crate) mod exports; @@ -95,35 +94,11 @@ use walrus::{ }; use wasm_opt::{OptimizationOptions, ShrinkLevel}; use wasmtime::{Engine, Linker, Store}; -use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder, p2::pipe::MemoryInputPipe}; +use wasmtime_wasi::{WasiCtxBuilder, p2::pipe::MemoryInputPipe}; use anyhow::Result; use wasmtime_wizer::Wizer; -struct FixedWallClock; - -impl HostWallClock for FixedWallClock { - fn resolution(&self) -> Duration { - Duration::from_secs(1) - } - - fn now(&self) -> Duration { - Duration::ZERO - } -} - -struct FixedMonotonicClock; - -impl HostMonotonicClock for FixedMonotonicClock { - fn resolution(&self) -> u64 { - 1_000_000_000 - } - - fn now(&self) -> u64 { - 0 - } -} - /// The kind of linking to use. #[derive(Debug, Clone, Default)] pub enum LinkingKind { @@ -266,9 +241,7 @@ impl Generator { .inherit_stdout() .inherit_stderr(); if self.deterministic { - builder - .wall_clock(FixedWallClock) - .monotonic_clock(FixedMonotonicClock); + javy_plugin_processing::with_determinism(&mut builder); } let wasi = builder.build_p1(); let mut store = Store::new(&engine, wasi); diff --git a/crates/plugin-processing/src/lib.rs b/crates/plugin-processing/src/lib.rs index 85153a425..7a6ff551a 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -31,13 +31,37 @@ impl HostMonotonicClock for FixedMonotonicClock { } } +/// Apply fixed clocks to a [`WasiCtxBuilder`] for deterministic builds, +/// ensuring identical Wizer output for identical input. +pub fn with_determinism(builder: &mut WasiCtxBuilder) -> &mut WasiCtxBuilder { + builder + .wall_clock(FixedWallClock) + .monotonic_clock(FixedMonotonicClock) +} + +/// Configuration for plugin initialization. +#[derive(Debug, Clone, Default)] +pub struct PluginConfig { + /// When true, use fixed clocks during Wizer pre-initialization so that + /// identical input always produces identical output. + pub deterministic: bool, +} + /// Extract core module if it's a component, then run wasm-opt and Wizer to /// initialize a plugin. pub async fn initialize_plugin(wasm_bytes: &[u8]) -> Result> { + initialize_plugin_with_config(wasm_bytes, &PluginConfig::default()).await +} + +/// Extract core module if it's a component, then run wasm-opt and Wizer to +/// initialize a plugin, using the provided configuration. +pub async fn initialize_plugin_with_config( + wasm_bytes: &[u8], + config: &PluginConfig, +) -> Result> { let wasm_bytes = extract_core_module_if_necessary(wasm_bytes)?; - // Re-encode overlong indexes with wasm-opt before running Wizer. let wasm_bytes = optimize_module(&wasm_bytes)?; - let wasm_bytes = preinitialize_module(&wasm_bytes).await?; + let wasm_bytes = preinitialize_module(&wasm_bytes, config).await?; Ok(wasm_bytes) } @@ -150,13 +174,14 @@ fn optimize_module(wasm_bytes: &[u8]) -> Result> { Ok(optimized_wasm_bytes) } -async fn preinitialize_module(wasm_bytes: &[u8]) -> Result> { +async fn preinitialize_module(wasm_bytes: &[u8], config: &PluginConfig) -> Result> { let engine = Engine::default(); - let wasi = WasiCtxBuilder::new() - .inherit_stderr() - .wall_clock(FixedWallClock) - .monotonic_clock(FixedMonotonicClock) - .build_p1(); + let mut builder = WasiCtxBuilder::new(); + builder.inherit_stderr(); + if config.deterministic { + with_determinism(&mut builder); + } + let wasi = builder.build_p1(); let mut store = Store::new(&engine, wasi); Ok(Wizer::new() diff --git a/crates/plugin-processing/src/main.rs b/crates/plugin-processing/src/main.rs index aef798fb1..d15431156 100644 --- a/crates/plugin-processing/src/main.rs +++ b/crates/plugin-processing/src/main.rs @@ -2,6 +2,7 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use clap::Parser; +use javy_plugin_processing::PluginConfig; #[derive(Parser)] #[command(about = "Initialize a Javy plugin")] @@ -11,13 +12,20 @@ struct Args { #[arg(help = "Output path for the initialized Javy plugin")] output: PathBuf, + + #[arg(long, help = "Use fixed clocks for deterministic output")] + deterministic: bool, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); + let config = PluginConfig { + deterministic: args.deterministic, + }; let wasm_bytes = fs::read(&args.input)?; - let wasm_bytes = javy_plugin_processing::initialize_plugin(&wasm_bytes).await?; + let wasm_bytes = + javy_plugin_processing::initialize_plugin_with_config(&wasm_bytes, &config).await?; fs::write(&args.output, wasm_bytes)?; Ok(()) } From 3cf00c1d768935cf8e9be05868e43b2200cf7067 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Tue, 10 Mar 2026 11:11:01 -0400 Subject: [PATCH 3/8] Add CLI E2E test for deterministic builds Adds `deterministic` option to the test runner Builder and an integration test that verifies two separate deterministic builds of the same JS source produce byte-identical WASM output. --- crates/cli/tests/integration_test.rs | 17 +++++++++++++++++ crates/runner/src/lib.rs | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/cli/tests/integration_test.rs b/crates/cli/tests/integration_test.rs index 5709ebd49..50a628d09 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -302,6 +302,23 @@ fn test_same_module_outputs_different_random_result(builder: &mut Builder) -> Re Ok(()) } +#[javy_cli_test] +fn test_deterministic_builds_produce_identical_wasm(builder: &mut Builder) -> Result<()> { + let mut builder2 = builder.clone(); + builder.input("random.js").deterministic(true); + builder2.input("random.js").deterministic(true); + + let runner1 = builder.build()?; + let runner2 = builder2.build()?; + + assert_eq!( + runner1.wasm, runner2.wasm, + "Two deterministic builds of the same source should produce identical WASM" + ); + Ok(()) +} + + #[javy_cli_test] fn test_exported_default_arrow_fn(builder: &mut Builder) -> Result<()> { let mut runner = builder diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index bbc772514..b737b70e4 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -95,6 +95,8 @@ pub struct Builder { plugin: Plugin, /// How to embed the source code. source_code: Option, + /// Whether to enable deterministic builds. + deterministic: Option, } impl Default for Builder { @@ -113,6 +115,7 @@ impl Default for Builder { event_loop: None, plugin: Plugin::Default, source_code: None, + deterministic: None, } } } @@ -178,6 +181,11 @@ impl Builder { self } + pub fn deterministic(&mut self, enabled: bool) -> &mut Self { + self.deterministic = Some(enabled); + self + } + pub fn build(&mut self) -> Result { if self.built { bail!("Builder already used to build a runner") @@ -203,6 +211,7 @@ impl Builder { preload, plugin, source_code, + deterministic, } = std::mem::take(self); self.built = true; @@ -220,6 +229,7 @@ impl Builder { preload, plugin, source_code, + deterministic, ) } } @@ -289,6 +299,7 @@ impl Runner { preload: Option<(String, PathBuf)>, plugin: Plugin, source_code: Option, + deterministic: Option, ) -> Result { // This directory is unique and will automatically get deleted // when `tempdir` goes out of scope. @@ -309,6 +320,7 @@ impl Runner { &event_loop, &plugin, &source_code, + &deterministic, ); Self::exec_command(bin, root, args)?; @@ -415,6 +427,7 @@ impl Runner { event_loop: &Option, plugin: &Plugin, source_code: &Option, + deterministic: &Option, ) -> Vec { let mut args = vec![ "build".to_string(), @@ -478,6 +491,14 @@ impl Runner { )); } + if let Some(enabled) = *deterministic { + args.push("-C".to_string()); + args.push(format!( + "deterministic={}", + if enabled { "y" } else { "n" } + )); + } + args } From 6b938f46bb6d1d0c274721aa02a592367f5807e0 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Tue, 10 Mar 2026 16:29:32 -0400 Subject: [PATCH 4/8] Fix non-deterministic init-plugin and make use of https://github.com/Shopify/deterministic-wasi-ctx The `with_determinism` helper only fixed wall and monotonic clocks but left `secure_random` and `insecure_random` using per-context random state. During Wizer pre-initialization the QuickJS runtime calls WASI random (e.g. for hash seeds), causing the memory snapshot to differ across runs. - Replace both secure and insecure random with constant zero-filled deterministic sources when deterministic mode is enabled - Disable parallel compilation and enable NaN canonicalization for the Wasmtime Engine in deterministic mode - Add `test_deterministic_init_plugin` test - Update CHANGELOG and all flag docs with security note about random sources being non-secure when determinism is active Made-with: Cursor --- Cargo.lock | 35 +++- crates/cli/src/commands.rs | 11 +- crates/cli/src/plugin.rs | 20 +++ crates/cli/tests/integration_test.rs | 32 ++-- .../sample-scripts/deterministic-complex.js | 150 ++++++++++++++++++ crates/codegen/CHANGELOG.md | 11 +- crates/codegen/Cargo.toml | 1 + crates/codegen/src/lib.rs | 8 +- crates/plugin-processing/Cargo.toml | 1 + crates/plugin-processing/src/lib.rs | 61 +++---- crates/plugin-processing/src/main.rs | 5 +- crates/runner/src/lib.rs | 5 +- 12 files changed, 275 insertions(+), 65 deletions(-) create mode 100644 crates/cli/tests/sample-scripts/deterministic-complex.js diff --git a/Cargo.lock b/Cargo.lock index f83072460..66ba10917 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,6 +889,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deterministic-wasi-ctx" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5125c0968a9a2fac679cfba0d143d6f6f400020fc1c58ab17d64e77c5dd8414" +dependencies = [ + "anyhow", + "async-trait", + "cap-primitives", + "rand_core 0.6.4", + "rand_pcg", + "wasi", + "wasmtime", + "wasmtime-wasi", +] + [[package]] name = "digest" version = "0.10.7" @@ -1003,7 +1019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1660,6 +1676,7 @@ dependencies = [ "anyhow", "brotli", "convert_case", + "deterministic-wasi-ctx", "insta", "javy-plugin-processing", "swc_core", @@ -1710,6 +1727,7 @@ version = "8.0.0" dependencies = [ "anyhow", "clap", + "deterministic-wasi-ctx", "tempfile", "tokio", "walrus", @@ -2252,6 +2270,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand_xoshiro" version = "0.6.0" @@ -2478,7 +2505,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3034,7 +3061,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3969,7 +3996,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 28373e495..ac0dca1d3 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -84,7 +84,9 @@ pub struct InitPluginCommandOpts { /// Output path for the initialized plugin binary (default is stdout). pub out: Option, #[arg(long)] - /// Use fixed clocks for deterministic output. + /// Produce deterministic output by using fixed clocks and constant + /// zero-filled RNG during pre-initialization. Security note: both + /// secure_random and insecure_random become non-secure. pub deterministic: bool, } @@ -191,9 +193,10 @@ option_group! { /// linked modules. JavaScript config options are also not supported when /// using this parameter. Plugin(PathBuf), - /// Produce deterministic output by using fixed clocks during - /// pre-initialization. Ensures identical input always produces - /// identical output. + /// Produce deterministic output by using fixed clocks and constant + /// zero-filled RNG during pre-initialization. Ensures identical input + /// always produces identical output. Security note: both + /// secure_random and insecure_random become non-secure. Deterministic(bool), } } diff --git a/crates/cli/src/plugin.rs b/crates/cli/src/plugin.rs index f728e467b..dcbdd1b43 100644 --- a/crates/cli/src/plugin.rs +++ b/crates/cli/src/plugin.rs @@ -115,4 +115,24 @@ mod tests { fn encode_as_component(module: &[u8]) -> Result> { ComponentEncoder::default().module(module)?.encode() } + + #[tokio::test] + async fn test_deterministic_init_plugin() -> Result<()> { + let plugin_bytes = super::PLUGIN_MODULE; + let config = javy_plugin_processing::PluginConfig { + deterministic: true, + }; + + let plugin = UninitializedPlugin::new(plugin_bytes)?; + let first = plugin.initialize_with_config(&config).await?; + + let plugin = UninitializedPlugin::new(plugin_bytes)?; + let second = plugin.initialize_with_config(&config).await?; + + assert_eq!( + first, second, + "deterministic init-plugin must produce identical output" + ); + Ok(()) + } } diff --git a/crates/cli/tests/integration_test.rs b/crates/cli/tests/integration_test.rs index 50a628d09..8ce82276f 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -304,21 +304,29 @@ fn test_same_module_outputs_different_random_result(builder: &mut Builder) -> Re #[javy_cli_test] fn test_deterministic_builds_produce_identical_wasm(builder: &mut Builder) -> Result<()> { - let mut builder2 = builder.clone(); - builder.input("random.js").deterministic(true); - builder2.input("random.js").deterministic(true); - - let runner1 = builder.build()?; - let runner2 = builder2.build()?; - - assert_eq!( - runner1.wasm, runner2.wasm, - "Two deterministic builds of the same source should produce identical WASM" - ); + // Uses a complex fixture with many functions, NaN-producing float ops, + // Math.random(), and Date.now() so that parallel compilation ordering + // and cranelift NaN canonicalization are exercised during Wizer + // pre-initialization. Multiple iterations increase the chance of + // catching thread-scheduling-dependent non-determinism. + let builds: Vec> = (0..5) + .map(|_| { + let mut b = builder.clone(); + b.input("deterministic-complex.js").deterministic(true); + b.build().map(|r| r.wasm) + }) + .collect::>()?; + + for (i, wasm) in builds.iter().enumerate().skip(1) { + assert_eq!( + builds[0], *wasm, + "deterministic build #{} differs from build #0", + i + ); + } Ok(()) } - #[javy_cli_test] fn test_exported_default_arrow_fn(builder: &mut Builder) -> Result<()> { let mut runner = builder diff --git a/crates/cli/tests/sample-scripts/deterministic-complex.js b/crates/cli/tests/sample-scripts/deterministic-complex.js new file mode 100644 index 000000000..7d6ec2006 --- /dev/null +++ b/crates/cli/tests/sample-scripts/deterministic-complex.js @@ -0,0 +1,150 @@ +// Exercises many runtime code paths during Wizer pre-initialization to stress +// parallel compilation ordering and NaN canonicalization in Cranelift. + +// --- Floating-point operations that produce NaN --- +const nanResults = []; +nanResults.push(0 / 0); +nanResults.push(Math.sqrt(-1)); +nanResults.push(parseFloat("not a number")); +nanResults.push(Math.log(-1)); +nanResults.push(Math.acos(2)); +nanResults.push(Math.asin(2)); +nanResults.push(Infinity - Infinity); +nanResults.push(Infinity * 0); +nanResults.push(undefined + 1); + +// --- Random number generation --- +const randomResults = []; +for (let i = 0; i < 50; i++) { + randomResults.push(Math.random()); +} + +// --- Date/time --- +const timestamps = []; +for (let i = 0; i < 10; i++) { + timestamps.push(Date.now()); + timestamps.push(new Date().toISOString()); +} + +// --- Many distinct functions to increase compiled function count --- +function fib(n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); } +function factorial(n) { return n <= 1 ? 1 : n * factorial(n - 1); } +function isPrime(n) { + if (n < 2) return false; + for (let i = 2; i * i <= n; i++) { if (n % i === 0) return false; } + return true; +} +function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); } +function lcm(a, b) { return (a / gcd(a, b)) * b; } + +function bubbleSort(arr) { + const a = arr.slice(); + for (let i = 0; i < a.length; i++) { + for (let j = 0; j < a.length - i - 1; j++) { + if (a[j] > a[j + 1]) { const t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; } + } + } + return a; +} + +function mergeSort(arr) { + if (arr.length <= 1) return arr; + const mid = Math.floor(arr.length / 2); + const left = mergeSort(arr.slice(0, mid)); + const right = mergeSort(arr.slice(mid)); + const result = []; + let i = 0, j = 0; + while (i < left.length && j < right.length) { + if (left[i] <= right[j]) result.push(left[i++]); + else result.push(right[j++]); + } + return result.concat(left.slice(i)).concat(right.slice(j)); +} + +function matMul(a, b) { + const rows = a.length, cols = b[0].length, inner = b.length; + const c = Array.from({ length: rows }, () => new Array(cols).fill(0)); + for (let i = 0; i < rows; i++) + for (let j = 0; j < cols; j++) + for (let k = 0; k < inner; k++) + c[i][j] += a[i][k] * b[k][j]; + return c; +} + +function sha256ish(str) { + let h = 0x6a09e667; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 0x5bd1e995); + h ^= h >>> 15; + } + return (h >>> 0).toString(16); +} + +function generatePermutations(arr) { + if (arr.length <= 1) return [arr]; + const result = []; + for (let i = 0; i < arr.length; i++) { + const rest = arr.slice(0, i).concat(arr.slice(i + 1)); + for (const perm of generatePermutations(rest)) { + result.push([arr[i], ...perm]); + } + } + return result; +} + +// --- Exercise all the functions at init time --- +const fibResults = []; +for (let i = 0; i < 20; i++) fibResults.push(fib(i)); + +const factResults = []; +for (let i = 0; i < 15; i++) factResults.push(factorial(i)); + +const primes = []; +for (let i = 0; i < 100; i++) { if (isPrime(i)) primes.push(i); } + +const gcdResults = []; +for (let i = 1; i <= 20; i++) { + for (let j = 1; j <= 20; j++) gcdResults.push(gcd(i, j)); +} + +const unsorted = Array.from({ length: 50 }, (_, i) => 50 - i); +const bubbled = bubbleSort(unsorted); +const merged = mergeSort(unsorted); + +const matA = [[1.1, 2.2, 3.3], [4.4, 5.5, 6.6], [7.7, 8.8, 9.9]]; +const matB = [[9.9, 8.8, 7.7], [6.6, 5.5, 4.4], [3.3, 2.2, 1.1]]; +const matC = matMul(matA, matB); + +const hashes = []; +for (let i = 0; i < 50; i++) hashes.push(sha256ish("test-string-" + i)); + +const perms = generatePermutations([1, 2, 3, 4, 5]); + +// --- Float operations that stress NaN propagation through computation --- +const floatChain = []; +let val = 1.0; +for (let i = 0; i < 100; i++) { + val = Math.sin(val) * Math.cos(val * 1.1) + Math.tan(val * 0.7); + if (isNaN(val)) val = 0.0; + floatChain.push(val); +} + +// --- Build the result --- +const result = { + nanCount: nanResults.filter(isNaN).length, + randomSample: randomResults.slice(0, 5), + timestamps: timestamps.slice(0, 4), + fib19: fibResults[19], + fact14: factResults[14], + primeCount: primes.length, + gcdSample: gcdResults.slice(0, 5), + sorted: bubbled.slice(0, 5), + merged: merged.slice(0, 5), + matC00: matC[0][0], + hashSample: hashes.slice(0, 3), + permCount: perms.length, + floatLast: floatChain[floatChain.length - 1], +}; + +const output = JSON.stringify(result); +Javy.IO.writeSync(1, new Uint8Array(new TextEncoder().encode(output))); diff --git a/crates/codegen/CHANGELOG.md b/crates/codegen/CHANGELOG.md index 3215f027f..07f6999b4 100644 --- a/crates/codegen/CHANGELOG.md +++ b/crates/codegen/CHANGELOG.md @@ -15,8 +15,15 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `Generator::deterministic()` and CLI `-C deterministic` option for reproducible - static-linked Wasm builds. When enabled, fixed clocks are used during Wizer - pre-initialization to avoid timestamp-induced non-determinism. + static-linked Wasm builds. When enabled, fixed clocks and deterministic + (zero-filled) RNG are used during Wizer pre-initialization to avoid + timestamp- and randomness-induced non-determinism. **Security note:** + `secure_random` and `insecure_random` are replaced with a constant + deterministic source when this flag is active — do not rely on WASI random + APIs for cryptographic security in deterministic mode. +- `init-plugin --deterministic` now also fixes both the secure and insecure + random sources and disables parallel compilation for fully reproducible + plugin initialization. ## [3.0.0] - 2025-11-12 diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index 797438e81..114de79b1 100644 --- a/crates/codegen/Cargo.toml +++ b/crates/codegen/Cargo.toml @@ -15,6 +15,7 @@ plugin_internal = [] [dependencies] anyhow = { workspace = true } brotli = { workspace = true } +deterministic-wasi-ctx = "4.0.0" javy-plugin-processing = { path = "../plugin-processing" } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 5e4258c99..8a890517c 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -234,14 +234,18 @@ impl Generator { let config = transform::module_config(); let module = match &self.linking { LinkingKind::Static => { - let engine = Engine::default(); + let engine = if self.deterministic { + javy_plugin_processing::with_deterministic_engine()? + } else { + Engine::default() + }; let mut builder = WasiCtxBuilder::new(); builder .stdin(MemoryInputPipe::new(self.js_runtime_config.clone())) .inherit_stdout() .inherit_stderr(); if self.deterministic { - javy_plugin_processing::with_determinism(&mut builder); + deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder); } let wasi = builder.build_p1(); let mut store = Store::new(&engine, wasi); diff --git a/crates/plugin-processing/Cargo.toml b/crates/plugin-processing/Cargo.toml index b095994af..23299a0c3 100644 --- a/crates/plugin-processing/Cargo.toml +++ b/crates/plugin-processing/Cargo.toml @@ -12,6 +12,7 @@ categories = ["wasm"] [dependencies] anyhow = { workspace = true } clap = { workspace = true } +deterministic-wasi-ctx = "4.0.0" tempfile = { workspace = true } tokio = { workspace = true, features = ["macros"] } walrus = { workspace = true } diff --git a/crates/plugin-processing/src/lib.rs b/crates/plugin-processing/src/lib.rs index 7a6ff551a..fc7d2de8a 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -1,49 +1,34 @@ use anyhow::{Result, bail}; -use std::time::Duration; use std::{borrow::Cow, fs}; use walrus::{FunctionId, ImportKind, ValType}; use wasmparser::{Parser, Payload}; -use wasmtime::{Engine, Linker, Store}; -use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; +use wasmtime::{Config, Engine, Linker, Store}; +use wasmtime_wasi::WasiCtxBuilder; use wasmtime_wizer::Wizer; -struct FixedWallClock; - -impl HostWallClock for FixedWallClock { - fn resolution(&self) -> Duration { - Duration::from_secs(1) - } - - fn now(&self) -> Duration { - Duration::ZERO - } -} - -struct FixedMonotonicClock; - -impl HostMonotonicClock for FixedMonotonicClock { - fn resolution(&self) -> u64 { - 1_000_000_000 - } - - fn now(&self) -> u64 { - 0 - } -} - -/// Apply fixed clocks to a [`WasiCtxBuilder`] for deterministic builds, -/// ensuring identical Wizer output for identical input. -pub fn with_determinism(builder: &mut WasiCtxBuilder) -> &mut WasiCtxBuilder { - builder - .wall_clock(FixedWallClock) - .monotonic_clock(FixedMonotonicClock) +/// Create a [`wasmtime::Engine`] configured for deterministic compilation. +/// +/// Disables parallel compilation to eliminate thread-scheduling-dependent +/// ordering in the compiled output, and enables Cranelift NaN +/// canonicalization to ensure consistent NaN bit patterns. +pub fn with_deterministic_engine() -> Result { + let mut cfg = Config::default(); + cfg.parallel_compilation(false); + cfg.cranelift_nan_canonicalization(true); + Ok(Engine::new(&cfg)?) } /// Configuration for plugin initialization. #[derive(Debug, Clone, Default)] pub struct PluginConfig { - /// When true, use fixed clocks during Wizer pre-initialization so that + /// When true, use fixed clocks, deterministic RNG (via + /// [`deterministic-wasi-ctx`](https://crates.io/crates/deterministic-wasi-ctx)), + /// and single-threaded compilation during Wizer pre-initialization so that /// identical input always produces identical output. + /// + /// **Security note:** This replaces both `secure_random` and + /// `insecure_random` with a seeded PRNG. WASI random APIs must not be + /// relied upon for cryptographic security when this is enabled. pub deterministic: bool, } @@ -175,11 +160,15 @@ fn optimize_module(wasm_bytes: &[u8]) -> Result> { } async fn preinitialize_module(wasm_bytes: &[u8], config: &PluginConfig) -> Result> { - let engine = Engine::default(); + let engine = if config.deterministic { + with_deterministic_engine()? + } else { + Engine::default() + }; let mut builder = WasiCtxBuilder::new(); builder.inherit_stderr(); if config.deterministic { - with_determinism(&mut builder); + deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder); } let wasi = builder.build_p1(); let mut store = Store::new(&engine, wasi); diff --git a/crates/plugin-processing/src/main.rs b/crates/plugin-processing/src/main.rs index d15431156..2891f9dfc 100644 --- a/crates/plugin-processing/src/main.rs +++ b/crates/plugin-processing/src/main.rs @@ -13,7 +13,10 @@ struct Args { #[arg(help = "Output path for the initialized Javy plugin")] output: PathBuf, - #[arg(long, help = "Use fixed clocks for deterministic output")] + #[arg( + long, + help = "Produce deterministic output by using fixed clocks and constant zero-filled RNG. Security note: both secure_random and insecure_random become non-secure." + )] deterministic: bool, } diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index b737b70e4..64c8465f5 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -493,10 +493,7 @@ impl Runner { if let Some(enabled) = *deterministic { args.push("-C".to_string()); - args.push(format!( - "deterministic={}", - if enabled { "y" } else { "n" } - )); + args.push(format!("deterministic={}", if enabled { "y" } else { "n" })); } args From 78c92bb5839237b1d4dc001b9ae5916743d219ce Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Wed, 11 Mar 2026 10:28:26 -0400 Subject: [PATCH 5/8] Remove PluginConfig in favor of direct dispatch --- crates/cli/src/main.rs | 11 ++++--- crates/cli/src/plugin.rs | 19 +++++------- crates/plugin-processing/src/lib.rs | 44 +++++++++++++--------------- crates/plugin-processing/src/main.rs | 15 +++++----- 4 files changed, 39 insertions(+), 50 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 30f88f90b..485058f25 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -63,13 +63,12 @@ async fn main() -> Result<()> { } Command::InitPlugin(opts) => { let plugin_bytes = fs::read(&opts.plugin)?; - let config = javy_plugin_processing::PluginConfig { - deterministic: opts.deterministic, - }; - let uninitialized_plugin = UninitializedPlugin::new(&plugin_bytes)?; - let initialized_plugin_bytes = - uninitialized_plugin.initialize_with_config(&config).await?; + let initialized_plugin_bytes = if opts.deterministic { + uninitialized_plugin.initialize_with_determinism().await? + } else { + uninitialized_plugin.initialize().await? + }; let mut out: Box = match opts.out.as_ref() { Some(path) => Box::new(File::create(path)?), diff --git a/crates/cli/src/plugin.rs b/crates/cli/src/plugin.rs index dcbdd1b43..248aee23c 100644 --- a/crates/cli/src/plugin.rs +++ b/crates/cli/src/plugin.rs @@ -47,17 +47,15 @@ impl<'a> UninitializedPlugin<'a> { Ok(Self { bytes }) } - /// Initializes the plugin with the provided configuration. - pub(crate) async fn initialize_with_config( - &self, - config: &javy_plugin_processing::PluginConfig, - ) -> Result> { - javy_plugin_processing::initialize_plugin_with_config(self.bytes, config).await + /// Initializes the plugin with deterministic clocks, RNG, and + /// single-threaded compilation so identical input produces identical output. + pub(crate) async fn initialize_with_determinism(&self) -> Result> { + javy_plugin_processing::initialize_plugin_with_determinism(self.bytes).await } /// Initializes the plugin with default (non-deterministic) configuration. pub(crate) async fn initialize(&self) -> Result> { - self.initialize_with_config(&Default::default()).await + javy_plugin_processing::initialize_plugin(self.bytes).await } fn validate(plugin_bytes: &'a [u8]) -> Result<()> { @@ -119,15 +117,12 @@ mod tests { #[tokio::test] async fn test_deterministic_init_plugin() -> Result<()> { let plugin_bytes = super::PLUGIN_MODULE; - let config = javy_plugin_processing::PluginConfig { - deterministic: true, - }; let plugin = UninitializedPlugin::new(plugin_bytes)?; - let first = plugin.initialize_with_config(&config).await?; + let first = plugin.initialize_with_determinism().await?; let plugin = UninitializedPlugin::new(plugin_bytes)?; - let second = plugin.initialize_with_config(&config).await?; + let second = plugin.initialize_with_determinism().await?; assert_eq!( first, second, diff --git a/crates/plugin-processing/src/lib.rs b/crates/plugin-processing/src/lib.rs index fc7d2de8a..efa86b27f 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -18,35 +18,31 @@ pub fn with_deterministic_engine() -> Result { Ok(Engine::new(&cfg)?) } -/// Configuration for plugin initialization. -#[derive(Debug, Clone, Default)] -pub struct PluginConfig { - /// When true, use fixed clocks, deterministic RNG (via - /// [`deterministic-wasi-ctx`](https://crates.io/crates/deterministic-wasi-ctx)), - /// and single-threaded compilation during Wizer pre-initialization so that - /// identical input always produces identical output. - /// - /// **Security note:** This replaces both `secure_random` and - /// `insecure_random` with a seeded PRNG. WASI random APIs must not be - /// relied upon for cryptographic security when this is enabled. - pub deterministic: bool, -} - /// Extract core module if it's a component, then run wasm-opt and Wizer to /// initialize a plugin. pub async fn initialize_plugin(wasm_bytes: &[u8]) -> Result> { - initialize_plugin_with_config(wasm_bytes, &PluginConfig::default()).await + initialize_plugin_helper(wasm_bytes, false) } /// Extract core module if it's a component, then run wasm-opt and Wizer to -/// initialize a plugin, using the provided configuration. -pub async fn initialize_plugin_with_config( - wasm_bytes: &[u8], - config: &PluginConfig, -) -> Result> { +/// initialize a plugin deterministically. +/// +/// Uses fixed clocks, deterministic RNG (via +/// [`deterministic-wasi-ctx`](https://crates.io/crates/deterministic-wasi-ctx)), +/// and single-threaded compilation during Wizer pre-initialization so that +/// identical input always produces identical output. +/// +/// **Security note:** This replaces both `secure_random` and +/// `insecure_random` with a seeded PRNG. WASI random APIs must not be +/// relied upon for cryptographic security when this is enabled. +pub async fn initialize_plugin_with_determinism(wasm_bytes: &[u8]) -> Result> { + initialize_plugin_helper(wasm_bytes, true) +} + +async fn initialize_plugin_helper(wasm_bytes: &[u8], determinism: bool) -> Result> { let wasm_bytes = extract_core_module_if_necessary(wasm_bytes)?; let wasm_bytes = optimize_module(&wasm_bytes)?; - let wasm_bytes = preinitialize_module(&wasm_bytes, config).await?; + let wasm_bytes = preinitialize_module(&wasm_bytes, determinism).await?; Ok(wasm_bytes) } @@ -159,15 +155,15 @@ fn optimize_module(wasm_bytes: &[u8]) -> Result> { Ok(optimized_wasm_bytes) } -async fn preinitialize_module(wasm_bytes: &[u8], config: &PluginConfig) -> Result> { - let engine = if config.deterministic { +async fn preinitialize_module(wasm_bytes: &[u8], deterministic: bool) -> Result> { + let engine = if deterministic { with_deterministic_engine()? } else { Engine::default() }; let mut builder = WasiCtxBuilder::new(); builder.inherit_stderr(); - if config.deterministic { + if deterministic { deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder); } let wasi = builder.build_p1(); diff --git a/crates/plugin-processing/src/main.rs b/crates/plugin-processing/src/main.rs index 2891f9dfc..ffccee660 100644 --- a/crates/plugin-processing/src/main.rs +++ b/crates/plugin-processing/src/main.rs @@ -2,9 +2,8 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use clap::Parser; -use javy_plugin_processing::PluginConfig; -#[derive(Parser)] +#[derive(clap::Parser)] #[command(about = "Initialize a Javy plugin")] struct Args { #[arg(help = "Path to the uninitialized Javy plugin")] @@ -15,7 +14,7 @@ struct Args { #[arg( long, - help = "Produce deterministic output by using fixed clocks and constant zero-filled RNG. Security note: both secure_random and insecure_random become non-secure." + help = "Produce deterministic output by using fixed clocks and seeded PRNG. Security note: both secure_random and insecure_random become non-secure." )] deterministic: bool, } @@ -23,12 +22,12 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - let config = PluginConfig { - deterministic: args.deterministic, - }; let wasm_bytes = fs::read(&args.input)?; - let wasm_bytes = - javy_plugin_processing::initialize_plugin_with_config(&wasm_bytes, &config).await?; + let wasm_bytes = if args.deterministic { + javy_plugin_processing::initialize_plugin_with_determinism(&wasm_bytes).await? + } else { + javy_plugin_processing::initialize_plugin(&wasm_bytes).await? + }; fs::write(&args.output, wasm_bytes)?; Ok(()) } From bfc65e36893e82bdb104391eeb8dcb97fcff456e Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Thu, 12 Mar 2026 13:59:41 -0400 Subject: [PATCH 6/8] PR feedback and use multiple files for the determinism check --- Cargo.lock | 1 - Cargo.toml | 1 + crates/cli/tests/integration_test.rs | 40 ++++---- .../sample-scripts/deterministic-complex.js | 10 -- .../sample-scripts/deterministic-math.js | 87 ++++++++++++++++ .../sample-scripts/deterministic-strings.js | 99 +++++++++++++++++++ crates/codegen/Cargo.toml | 3 +- crates/codegen/src/lib.rs | 7 +- crates/plugin-processing/Cargo.toml | 2 +- crates/plugin-processing/src/lib.rs | 24 +---- 10 files changed, 219 insertions(+), 55 deletions(-) create mode 100644 crates/cli/tests/sample-scripts/deterministic-math.js create mode 100644 crates/cli/tests/sample-scripts/deterministic-strings.js diff --git a/Cargo.lock b/Cargo.lock index 66ba10917..7271deb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1678,7 +1678,6 @@ dependencies = [ "convert_case", "deterministic-wasi-ctx", "insta", - "javy-plugin-processing", "swc_core", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e52b75574..cdb359f7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ license = "Apache-2.0 WITH LLVM-exception" [workspace.dependencies] brotli = "8.0.2" clap = { version = "4.5.60", features = ["derive"] } +deterministic-wasi-ctx = "4.0.0" wasmtime = "42" wasmtime-wasi = "42" wasmtime-wizer = "42" diff --git a/crates/cli/tests/integration_test.rs b/crates/cli/tests/integration_test.rs index 8ce82276f..6954db842 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -304,25 +304,27 @@ fn test_same_module_outputs_different_random_result(builder: &mut Builder) -> Re #[javy_cli_test] fn test_deterministic_builds_produce_identical_wasm(builder: &mut Builder) -> Result<()> { - // Uses a complex fixture with many functions, NaN-producing float ops, - // Math.random(), and Date.now() so that parallel compilation ordering - // and cranelift NaN canonicalization are exercised during Wizer - // pre-initialization. Multiple iterations increase the chance of - // catching thread-scheduling-dependent non-determinism. - let builds: Vec> = (0..5) - .map(|_| { - let mut b = builder.clone(); - b.input("deterministic-complex.js").deterministic(true); - b.build().map(|r| r.wasm) - }) - .collect::>()?; - - for (i, wasm) in builds.iter().enumerate().skip(1) { - assert_eq!( - builds[0], *wasm, - "deterministic build #{} differs from build #0", - i - ); + let scripts = [ + "deterministic-complex.js", + "deterministic-math.js", + "deterministic-strings.js", + ]; + + for script in &scripts { + let builds: Vec> = (0..5) + .map(|_| { + let mut b = builder.clone(); + b.input(script).deterministic(true); + b.build().map(|r| r.wasm) + }) + .collect::>()?; + + for (i, wasm) in builds.iter().enumerate().skip(1) { + assert_eq!( + builds[0], *wasm, + "deterministic build of {script} #{i} differs from build #0", + ); + } } Ok(()) } diff --git a/crates/cli/tests/sample-scripts/deterministic-complex.js b/crates/cli/tests/sample-scripts/deterministic-complex.js index 7d6ec2006..445a794cb 100644 --- a/crates/cli/tests/sample-scripts/deterministic-complex.js +++ b/crates/cli/tests/sample-scripts/deterministic-complex.js @@ -1,7 +1,3 @@ -// Exercises many runtime code paths during Wizer pre-initialization to stress -// parallel compilation ordering and NaN canonicalization in Cranelift. - -// --- Floating-point operations that produce NaN --- const nanResults = []; nanResults.push(0 / 0); nanResults.push(Math.sqrt(-1)); @@ -13,20 +9,17 @@ nanResults.push(Infinity - Infinity); nanResults.push(Infinity * 0); nanResults.push(undefined + 1); -// --- Random number generation --- const randomResults = []; for (let i = 0; i < 50; i++) { randomResults.push(Math.random()); } -// --- Date/time --- const timestamps = []; for (let i = 0; i < 10; i++) { timestamps.push(Date.now()); timestamps.push(new Date().toISOString()); } -// --- Many distinct functions to increase compiled function count --- function fib(n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); } function factorial(n) { return n <= 1 ? 1 : n * factorial(n - 1); } function isPrime(n) { @@ -92,7 +85,6 @@ function generatePermutations(arr) { return result; } -// --- Exercise all the functions at init time --- const fibResults = []; for (let i = 0; i < 20; i++) fibResults.push(fib(i)); @@ -120,7 +112,6 @@ for (let i = 0; i < 50; i++) hashes.push(sha256ish("test-string-" + i)); const perms = generatePermutations([1, 2, 3, 4, 5]); -// --- Float operations that stress NaN propagation through computation --- const floatChain = []; let val = 1.0; for (let i = 0; i < 100; i++) { @@ -129,7 +120,6 @@ for (let i = 0; i < 100; i++) { floatChain.push(val); } -// --- Build the result --- const result = { nanCount: nanResults.filter(isNaN).length, randomSample: randomResults.slice(0, 5), diff --git a/crates/cli/tests/sample-scripts/deterministic-math.js b/crates/cli/tests/sample-scripts/deterministic-math.js new file mode 100644 index 000000000..ccb7b0080 --- /dev/null +++ b/crates/cli/tests/sample-scripts/deterministic-math.js @@ -0,0 +1,87 @@ +function collatz(n) { + const seq = [n]; + while (n !== 1) { + n = n % 2 === 0 ? n / 2 : 3 * n + 1; + seq.push(n); + } + return seq; +} + +function sieve(limit) { + const flags = new Array(limit + 1).fill(true); + flags[0] = flags[1] = false; + for (let i = 2; i * i <= limit; i++) { + if (flags[i]) { + for (let j = i * i; j <= limit; j += i) flags[j] = false; + } + } + return flags.reduce((acc, v, i) => { if (v) acc.push(i); return acc; }, []); +} + +function mandelbrot(cx, cy, maxIter) { + let x = 0, y = 0, iter = 0; + while (x * x + y * y <= 4 && iter < maxIter) { + const tmp = x * x - y * y + cx; + y = 2 * x * y + cy; + x = tmp; + iter++; + } + return iter; +} + +function luDecompose(matrix) { + const n = matrix.length; + const L = Array.from({ length: n }, (_, i) => { + const row = new Array(n).fill(0); + row[i] = 1; + return row; + }); + const U = matrix.map(r => r.slice()); + for (let k = 0; k < n; k++) { + for (let i = k + 1; i < n; i++) { + const factor = U[i][k] / U[k][k]; + L[i][k] = factor; + for (let j = k; j < n; j++) U[i][j] -= factor * U[k][j]; + } + } + return { L, U }; +} + +const collatzResults = []; +for (let i = 1; i <= 30; i++) collatzResults.push(collatz(i).length); + +const primes = sieve(500); + +const mandelbrotGrid = []; +for (let y = -1.0; y <= 1.0; y += 0.1) { + for (let x = -2.0; x <= 0.5; x += 0.1) { + mandelbrotGrid.push(mandelbrot(x, y, 100)); + } +} + +const mat = [ + [2.5, 1.3, 0.7, 3.1], + [0.4, 5.2, 2.8, 1.6], + [3.9, 0.8, 4.5, 2.3], + [1.2, 3.7, 0.6, 6.1], +]; +const { L, U } = luDecompose(mat); + +const randomWalk = []; +let pos = 0; +for (let i = 0; i < 200; i++) { + pos += Math.random() > 0.5 ? 1 : -1; + randomWalk.push(pos); +} + +const result = { + collatzLengths: collatzResults, + primeCount: primes.length, + mandelbrotSum: mandelbrotGrid.reduce((a, b) => a + b, 0), + luTrace: L[3][0] + U[0][0], + walkFinal: randomWalk[randomWalk.length - 1], + timestamps: [Date.now(), Date.now()], +}; + +const output = JSON.stringify(result); +Javy.IO.writeSync(1, new Uint8Array(new TextEncoder().encode(output))); diff --git a/crates/cli/tests/sample-scripts/deterministic-strings.js b/crates/cli/tests/sample-scripts/deterministic-strings.js new file mode 100644 index 000000000..814b3abf3 --- /dev/null +++ b/crates/cli/tests/sample-scripts/deterministic-strings.js @@ -0,0 +1,99 @@ +function rot13(str) { + return str.replace(/[a-zA-Z]/g, c => { + const base = c <= 'Z' ? 65 : 97; + return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base); + }); +} + +function levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +function longestCommonSubsequence(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + return dp[m][n]; +} + +function huffmanFreqs(str) { + const freq = {}; + for (const c of str) freq[c] = (freq[c] || 0) + 1; + return Object.entries(freq).sort((a, b) => b[1] - a[1]); +} + +function kmpSearch(text, pattern) { + const lps = new Array(pattern.length).fill(0); + let len = 0, i = 1; + while (i < pattern.length) { + if (pattern[i] === pattern[len]) { lps[i++] = ++len; } + else if (len) { len = lps[len - 1]; } + else { lps[i++] = 0; } + } + const matches = []; + let ti = 0, pi = 0; + while (ti < text.length) { + if (text[ti] === pattern[pi]) { ti++; pi++; } + if (pi === pattern.length) { matches.push(ti - pi); pi = lps[pi - 1]; } + else if (ti < text.length && text[ti] !== pattern[pi]) { + if (pi) pi = lps[pi - 1]; else ti++; + } + } + return matches; +} + +const words = ["deterministic", "compilation", "webassembly", "javascript", "quickjs", + "runtime", "bytecode", "interpreter", "function", "module", "export", "import", + "memory", "linear", "stack", "heap", "garbage", "collector", "prototype", "closure"]; + +const rotated = words.map(rot13); +const distances = []; +for (let i = 0; i < words.length; i++) { + for (let j = i + 1; j < words.length; j++) { + distances.push(levenshtein(words[i], words[j])); + } +} + +const longText = words.join(" ").repeat(10); +const lcsResult = longestCommonSubsequence(words[0], words[1]); +const freqs = huffmanFreqs(longText); +const searchHits = kmpSearch(longText, "tion"); + +const randomStrings = []; +for (let i = 0; i < 30; i++) { + let s = ""; + for (let j = 0; j < 20; j++) { + s += String.fromCharCode(97 + Math.floor(Math.random() * 26)); + } + randomStrings.push(s); +} + +const result = { + rotSample: rotated.slice(0, 5), + distanceSum: distances.reduce((a, b) => a + b, 0), + lcs: lcsResult, + topFreq: freqs.slice(0, 3), + searchCount: searchHits.length, + randomSample: randomStrings.slice(0, 3), + timestamp: Date.now(), +}; + +const output = JSON.stringify(result); +Javy.IO.writeSync(1, new Uint8Array(new TextEncoder().encode(output))); diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index 114de79b1..0030d77be 100644 --- a/crates/codegen/Cargo.toml +++ b/crates/codegen/Cargo.toml @@ -15,8 +15,7 @@ plugin_internal = [] [dependencies] anyhow = { workspace = true } brotli = { workspace = true } -deterministic-wasi-ctx = "4.0.0" -javy-plugin-processing = { path = "../plugin-processing" } +deterministic-wasi-ctx = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wizer = { workspace = true, features = ["wasmtime"] } diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 8a890517c..d64989f1b 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -93,7 +93,7 @@ use walrus::{ DataId, DataKind, ExportItem, FunctionBuilder, FunctionId, LocalId, MemoryId, Module, ValType, }; use wasm_opt::{OptimizationOptions, ShrinkLevel}; -use wasmtime::{Engine, Linker, Store}; +use wasmtime::{Config, Engine, Linker, Store}; use wasmtime_wasi::{WasiCtxBuilder, p2::pipe::MemoryInputPipe}; use anyhow::Result; @@ -235,7 +235,10 @@ impl Generator { let module = match &self.linking { LinkingKind::Static => { let engine = if self.deterministic { - javy_plugin_processing::with_deterministic_engine()? + let mut cfg = Config::default(); + cfg.parallel_compilation(false); + cfg.cranelift_nan_canonicalization(true); + Engine::new(&cfg)? } else { Engine::default() }; diff --git a/crates/plugin-processing/Cargo.toml b/crates/plugin-processing/Cargo.toml index 23299a0c3..8857c1056 100644 --- a/crates/plugin-processing/Cargo.toml +++ b/crates/plugin-processing/Cargo.toml @@ -12,7 +12,7 @@ categories = ["wasm"] [dependencies] anyhow = { workspace = true } clap = { workspace = true } -deterministic-wasi-ctx = "4.0.0" +deterministic-wasi-ctx = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros"] } walrus = { workspace = true } diff --git a/crates/plugin-processing/src/lib.rs b/crates/plugin-processing/src/lib.rs index efa86b27f..2dbad2225 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -2,26 +2,14 @@ use anyhow::{Result, bail}; use std::{borrow::Cow, fs}; use walrus::{FunctionId, ImportKind, ValType}; use wasmparser::{Parser, Payload}; -use wasmtime::{Config, Engine, Linker, Store}; +use wasmtime::{Engine, Linker, Store}; use wasmtime_wasi::WasiCtxBuilder; use wasmtime_wizer::Wizer; -/// Create a [`wasmtime::Engine`] configured for deterministic compilation. -/// -/// Disables parallel compilation to eliminate thread-scheduling-dependent -/// ordering in the compiled output, and enables Cranelift NaN -/// canonicalization to ensure consistent NaN bit patterns. -pub fn with_deterministic_engine() -> Result { - let mut cfg = Config::default(); - cfg.parallel_compilation(false); - cfg.cranelift_nan_canonicalization(true); - Ok(Engine::new(&cfg)?) -} - /// Extract core module if it's a component, then run wasm-opt and Wizer to /// initialize a plugin. pub async fn initialize_plugin(wasm_bytes: &[u8]) -> Result> { - initialize_plugin_helper(wasm_bytes, false) + initialize_plugin_helper(wasm_bytes, false).await } /// Extract core module if it's a component, then run wasm-opt and Wizer to @@ -36,7 +24,7 @@ pub async fn initialize_plugin(wasm_bytes: &[u8]) -> Result> { /// `insecure_random` with a seeded PRNG. WASI random APIs must not be /// relied upon for cryptographic security when this is enabled. pub async fn initialize_plugin_with_determinism(wasm_bytes: &[u8]) -> Result> { - initialize_plugin_helper(wasm_bytes, true) + initialize_plugin_helper(wasm_bytes, true).await } async fn initialize_plugin_helper(wasm_bytes: &[u8], determinism: bool) -> Result> { @@ -156,11 +144,7 @@ fn optimize_module(wasm_bytes: &[u8]) -> Result> { } async fn preinitialize_module(wasm_bytes: &[u8], deterministic: bool) -> Result> { - let engine = if deterministic { - with_deterministic_engine()? - } else { - Engine::default() - }; + let engine = Engine::default(); let mut builder = WasiCtxBuilder::new(); builder.inherit_stderr(); if deterministic { From 4e6e31efd59d2fb18c19b5637ad6c5e6e3b5e9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Cabrera?= Date: Fri, 13 Mar 2026 11:14:44 -0400 Subject: [PATCH 7/8] Avoid compilation configuration --- crates/codegen/src/lib.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index d64989f1b..2e7b063ce 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -93,7 +93,7 @@ use walrus::{ DataId, DataKind, ExportItem, FunctionBuilder, FunctionId, LocalId, MemoryId, Module, ValType, }; use wasm_opt::{OptimizationOptions, ShrinkLevel}; -use wasmtime::{Config, Engine, Linker, Store}; +use wasmtime::{Engine, Linker, Store}; use wasmtime_wasi::{WasiCtxBuilder, p2::pipe::MemoryInputPipe}; use anyhow::Result; @@ -234,14 +234,7 @@ impl Generator { let config = transform::module_config(); let module = match &self.linking { LinkingKind::Static => { - let engine = if self.deterministic { - let mut cfg = Config::default(); - cfg.parallel_compilation(false); - cfg.cranelift_nan_canonicalization(true); - Engine::new(&cfg)? - } else { - Engine::default() - }; + let engine = Engine::default(); let mut builder = WasiCtxBuilder::new(); builder .stdin(MemoryInputPipe::new(self.js_runtime_config.clone())) From c83ce1ed9f0409954d2b77764ea82087f5d2c799 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Mon, 16 Mar 2026 09:44:42 -0400 Subject: [PATCH 8/8] Add CLI E2E test for init-plugin --deterministic Invokes init-plugin --deterministic twice on the same uninitialized plugin and asserts byte-identical output, then verifies the resulting plugin is functional. Made-with: Cursor --- crates/cli/tests/integration_test.rs | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/cli/tests/integration_test.rs b/crates/cli/tests/integration_test.rs index 6954db842..d104d7fde 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -473,6 +473,56 @@ fn test_init_plugin() -> Result<()> { Ok(()) } +#[test] +fn test_init_plugin_deterministic() -> Result<()> { + let uninitialized_plugin = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join( + std::path::Path::new("target") + .join("wasm32-wasip1") + .join("release") + .join("plugin.wasm"), + ); + + let init_deterministic = || -> Result> { + let output = Command::new(env!("CARGO_BIN_EXE_javy")) + .arg("init-plugin") + .arg("--deterministic") + .arg(uninitialized_plugin.to_str().unwrap()) + .output()?; + if !output.status.success() { + bail!( + "init-plugin --deterministic failed: {}", + str::from_utf8(&output.stderr)?, + ); + } + Ok(output.stdout) + }; + + let first = init_deterministic()?; + let second = init_deterministic()?; + + assert_eq!( + first, second, + "init-plugin --deterministic must produce identical output across invocations" + ); + + // Verify the initialized plugin is functional. + let engine = Engine::default(); + let mut linker = Linker::new(&engine); + wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s| s)?; + let wasi = WasiCtxBuilder::new().build_p1(); + let mut store = Store::new(&engine, wasi); + let module = Module::new(&engine, &first)?; + let instance = linker.instantiate(store.as_context_mut(), &module)?; + instance + .get_typed_func::<(i32, i32), i32>(store.as_context_mut(), "compile-src")? + .call(store.as_context_mut(), (0, 0))?; + + Ok(()) +} + fn run_with_u8s(r: &mut Runner, stdin: u8) -> (u8, String, u64) { let (output, logs, fuel_consumed) = run(r, stdin.to_le_bytes().into()); assert_eq!(1, output.len());