diff --git a/Cargo.lock b/Cargo.lock index ec6957a0c..7271deb41 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]] @@ -1655,11 +1671,12 @@ dependencies = [ [[package]] name = "javy-codegen" -version = "4.0.0-alpha.1" +version = "4.0.0-alpha.2" dependencies = [ "anyhow", "brotli", "convert_case", + "deterministic-wasi-ctx", "insta", "swc_core", "tempfile", @@ -1709,6 +1726,7 @@ version = "8.0.0" dependencies = [ "anyhow", "clap", + "deterministic-wasi-ctx", "tempfile", "tokio", "walrus", @@ -2251,6 +2269,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" @@ -2477,7 +2504,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3033,7 +3060,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3968,7 +3995,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/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/src/commands.rs b/crates/cli/src/commands.rs index de6f8d353..ac0dca1d3 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -83,6 +83,11 @@ pub struct InitPluginCommandOpts { #[arg(short, long = "out")] /// Output path for the initialized plugin binary (default is stdout). pub out: Option, + #[arg(long)] + /// 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, } impl ValueParserFactory for GroupOption @@ -151,6 +156,7 @@ pub struct CodegenOptionGroup { pub wit: WitOptions, pub source: Source, pub plugin: Option, + pub deterministic: bool, } impl Default for CodegenOptionGroup { @@ -160,6 +166,7 @@ impl Default for CodegenOptionGroup { wit: WitOptions::default(), source: Source::Compressed, plugin: None, + deterministic: false, } } } @@ -186,6 +193,11 @@ option_group! { /// linked modules. JavaScript config options are also not supported when /// using this parameter. Plugin(PathBuf), + /// 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), } } @@ -202,6 +214,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 +253,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..485058f25 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); @@ -62,9 +63,12 @@ async fn main() -> Result<()> { } Command::InitPlugin(opts) => { let plugin_bytes = fs::read(&opts.plugin)?; - let uninitialized_plugin = UninitializedPlugin::new(&plugin_bytes)?; - let initialized_plugin_bytes = uninitialized_plugin.initialize().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 5fb071042..248aee23c 100644 --- a/crates/cli/src/plugin.rs +++ b/crates/cli/src/plugin.rs @@ -47,7 +47,13 @@ impl<'a> UninitializedPlugin<'a> { Ok(Self { bytes }) } - /// Initializes the plugin. + /// 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> { javy_plugin_processing::initialize_plugin(self.bytes).await } @@ -107,4 +113,21 @@ 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 plugin = UninitializedPlugin::new(plugin_bytes)?; + let first = plugin.initialize_with_determinism().await?; + + let plugin = UninitializedPlugin::new(plugin_bytes)?; + let second = plugin.initialize_with_determinism().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 5709ebd49..d104d7fde 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -302,6 +302,33 @@ 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 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(()) +} + #[javy_cli_test] fn test_exported_default_arrow_fn(builder: &mut Builder) -> Result<()> { let mut runner = builder @@ -446,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()); 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..445a794cb --- /dev/null +++ b/crates/cli/tests/sample-scripts/deterministic-complex.js @@ -0,0 +1,140 @@ +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); + +const randomResults = []; +for (let i = 0; i < 50; i++) { + randomResults.push(Math.random()); +} + +const timestamps = []; +for (let i = 0; i < 10; i++) { + timestamps.push(Date.now()); + timestamps.push(new Date().toISOString()); +} + +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; +} + +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]); + +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); +} + +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/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/CHANGELOG.md b/crates/codegen/CHANGELOG.md index fc49855af..07f6999b4 100644 --- a/crates/codegen/CHANGELOG.md +++ b/crates/codegen/CHANGELOG.md @@ -12,6 +12,19 @@ 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 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 ### Changed diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index 8768efbdc..0030d77be 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 @@ -15,6 +15,7 @@ plugin_internal = [] [dependencies] anyhow = { workspace = true } brotli = { workspace = true } +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 39f6789af..2e7b063ce 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 //! @@ -174,6 +176,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 +219,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 +235,15 @@ 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 { + deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder); + } + 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/Cargo.toml b/crates/plugin-processing/Cargo.toml index b095994af..8857c1056 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 = { 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 58180be47..2dbad2225 100644 --- a/crates/plugin-processing/src/lib.rs +++ b/crates/plugin-processing/src/lib.rs @@ -9,10 +9,28 @@ use wasmtime_wizer::Wizer; /// 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).await +} + +/// Extract core module if it's a component, then run wasm-opt and Wizer to +/// 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).await +} + +async fn initialize_plugin_helper(wasm_bytes: &[u8], determinism: bool) -> 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, determinism).await?; Ok(wasm_bytes) } @@ -125,9 +143,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], deterministic: bool) -> Result> { let engine = Engine::default(); - let wasi = WasiCtxBuilder::new().inherit_stderr().build_p1(); + let mut builder = WasiCtxBuilder::new(); + builder.inherit_stderr(); + if deterministic { + deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&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..ffccee660 100644 --- a/crates/plugin-processing/src/main.rs +++ b/crates/plugin-processing/src/main.rs @@ -3,7 +3,7 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use clap::Parser; -#[derive(Parser)] +#[derive(clap::Parser)] #[command(about = "Initialize a Javy plugin")] struct Args { #[arg(help = "Path to the uninitialized Javy plugin")] @@ -11,13 +11,23 @@ struct Args { #[arg(help = "Output path for the initialized Javy plugin")] output: PathBuf, + + #[arg( + long, + help = "Produce deterministic output by using fixed clocks and seeded PRNG. Security note: both secure_random and insecure_random become non-secure." + )] + deterministic: bool, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); let wasm_bytes = fs::read(&args.input)?; - let wasm_bytes = javy_plugin_processing::initialize_plugin(&wasm_bytes).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(()) } diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index bbc772514..64c8465f5 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,11 @@ impl Runner { )); } + if let Some(enabled) = *deterministic { + args.push("-C".to_string()); + args.push(format!("deterministic={}", if enabled { "y" } else { "n" })); + } + args }