From f73867a47ade41dbe8e5c77a42bc58781ed9d441 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Mon, 12 Oct 2026 17:18:45 +0900 Subject: [PATCH 1/2] fix: protect compiler inputs from artifact and report outputs Address #901, #902, #905 and #906 with pre-write alias checks and input-preservation regressions. Signed-off-by: LunaStev --- llvm/src/msvc/sdk.rs | 33 ++++++++++ src/cli.rs | 43 ++++++++++-- src/link_outputs.rs | 96 +++++++++++++++++++++------ src/module_resolver.rs | 28 ++++++++ src/runner.rs | 15 +++++ tests/release_stabilization.rs | 108 +++++++++++++++++++++++++++++++ tools/check_case_sources.py | 17 +++-- tools/ci/common.py | 1 + tools/run_runtime_cases.py | 10 ++- tools/run_tests.py | 9 ++- tools/test_check_case_sources.py | 37 +++++++++++ tools/test_ci.py | 29 +++++++++ tools/test_run_tests.py | 41 ++++++++++++ tools/test_runtime_cases.py | 32 +++++++++ tools/validation_reports.py | 14 ++++ 15 files changed, 478 insertions(+), 35 deletions(-) diff --git a/llvm/src/msvc/sdk.rs b/llvm/src/msvc/sdk.rs index cce5ab78..829621d7 100644 --- a/llvm/src/msvc/sdk.rs +++ b/llvm/src/msvc/sdk.rs @@ -200,6 +200,39 @@ fn find_library(name: &str, paths: &[PathBuf]) -> Option { paths.iter().map(|p| p.join(&file)).find(|p| p.is_file()) } +/// Resolve existing file inputs for the driver's no-clobber check. Missing +/// libraries still receive the normal SDK diagnostic in validate_arguments. +pub fn existing_link_inputs(objects: &[String], args: &[String]) -> Vec { + let paths: Vec<_> = + args.iter().filter_map(|a| value(a, "/LIBPATH:")).map(PathBuf::from).collect(); + let no_defaults = args.iter().any(|a| a.eq_ignore_ascii_case("/NODEFAULTLIB")); + let mut files: Vec<_> = objects.iter().map(PathBuf::from).filter(|p| p.is_file()).collect(); + for arg in args { + let library = if let Some(name) = value(arg, "/DEFAULTLIB:") { + (!no_defaults).then_some(name) + } else if let Some(name) = value(arg, "/WHOLEARCHIVE:") { + Some(name) + } else if Path::new(arg).extension().is_some_and(|e| e.eq_ignore_ascii_case("lib")) { + Some(arg.as_str()) + } else { + None + }; + if let Some(path) = library.and_then(|name| find_library(name, &paths)) { + files.push(path); + } else if let Some(path) = value(arg, "/DEF:") { + let path = PathBuf::from(path); + if path.is_file() { + files.push(path); + } + } else if Path::new(arg).is_file() { + files.push(PathBuf::from(arg)); + } + } + files.sort(); + files.dedup(); + files +} + /// Inspect explicitly resolved inputs before either linker is launched. Object /// directives are still interpreted by the linker; no claim of LTO validation. pub fn validate_arguments(target: &str, args: &[String]) -> Result<(), String> { diff --git a/src/cli.rs b/src/cli.rs index 53426034..c76b7419 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1875,6 +1875,14 @@ fn validate_output_paths( let Some(emits) = build.emit.as_set() else { return Ok(()); }; + let msvc_link = plan + .link_output + .as_ref() + .filter(|_| llvm::backend::is_windows_msvc_target(&target_triple_for_global(global))) + .map(|output| { + let (_, args) = build_linker_args(global, build, &plan.link_inputs, output); + (output, args) + }); let mut outputs = Vec::new(); for job in &plan.compile_jobs { outputs.push((job.output.clone(), None)); @@ -1882,6 +1890,14 @@ fn validate_output_paths( if let Some(output) = &plan.link_output { outputs.push((output.clone(), None)); } + if let Some((output, args)) = &msvc_link { + outputs.extend( + crate::link_outputs::MsvcOutputs::destinations(output, args) + .into_iter() + .skip(1) + .map(|path| (path, None)), + ); + } for (index, input) in inputs.iter().enumerate() { for kind in [EmitKind::Ast, EmitKind::Ir, EmitKind::Bc, EmitKind::Asm] { if emits.contains(&kind) && supports_emit_for_input(kind, input.kind) { @@ -1899,15 +1915,28 @@ fn validate_output_paths( } } let mut sources: Vec = inputs.iter().map(|input| input.path.clone()).collect(); - crate::output_guard::validate(&sources, &outputs)?; - if emits.len() == 1 && emits.contains(&EmitKind::Ast) { - return Ok(()); + if let Some((_, args)) = &msvc_link { + // An object from a previous build is a compile output at this phase, + // not an input to preserve. Once regenerated, link_objects protects it + // against companion publication. Explicit command-line inputs remain + // protected by the classified input list above. + sources.extend( + llvm::msvc::sdk::existing_link_inputs(&plan.link_inputs, args) + .into_iter() + .filter(|path| !plan.compile_jobs.iter().any(|job| &job.output == path)), + ); + sources.extend(llvm::msvc::sdk::existing_link_inputs(&[], &global.llvm.link_args)); } + crate::output_guard::validate(&sources, &outputs)?; // Imported source files are compiler inputs too. Resolve them before any // emit or object job writes an artifact, including the final link output. if !build.dry_run { for input in inputs.iter().filter(|input| input.kind == InputKind::Wave) { - sources.extend(runner::wave_input_paths(&input.path, &global.dep, &global.llvm)); + sources.extend(if emits.len() == 1 && emits.contains(&EmitKind::Ast) { + runner::wave_ast_input_paths(&input.path, &global.dep, &global.llvm) + } else { + runner::wave_input_paths(&input.path, &global.dep, &global.llvm) + }); } crate::output_guard::validate(&sources, &outputs)?; } @@ -2460,6 +2489,12 @@ fn link_objects( } let (pending, bin, args) = if llvm::backend::is_windows_msvc_target(&target) { let (bin, mut args) = build_linker_args(global, build, objects, output); + let inputs = llvm::msvc::sdk::existing_link_inputs(objects, &args); + let outputs: Vec<_> = crate::link_outputs::MsvcOutputs::destinations(output, &args) + .into_iter() + .map(|path| (path, None)) + .collect(); + crate::output_guard::validate(&inputs, &outputs)?; llvm::msvc::sdk::validate_link_inputs(&target, objects, &args) .map_err(CliError::CommandFailed)?; let pending = crate::link_outputs::MsvcOutputs::prepare(output, &mut args)?; diff --git a/src/link_outputs.rs b/src/link_outputs.rs index 9e078393..e5aae5e0 100644 --- a/src/link_outputs.rs +++ b/src/link_outputs.rs @@ -35,7 +35,20 @@ pub(crate) struct MsvcOutputs { } impl MsvcOutputs { + /// Plan the same destinations as staging, before creating any output files. + pub(crate) fn destinations(output: &Path, args: &[String]) -> Vec { + let mut paths = vec![output.to_owned()]; + for (option, path) in companion_paths(output, args) { + paths.push(path.clone()); + if option == "IMPLIB" { + paths.push(path.with_extension("exp")); + } + } + paths + } + pub(crate) fn prepare(output: &Path, args: &mut Vec) -> io::Result { + let companions = companion_paths(output, args); let mut set = Self { outputs: Vec::new(), directories: Vec::new(), preserve_backups: false }; let image = set.stage(output)?; @@ -46,31 +59,23 @@ impl MsvcOutputs { } // LINK derives the .exp basename from the import library, when it emits // that companion. Both share their final directory and basename here. - let library = option_path(args, "IMPLIB").unwrap_or_else(|| output.with_extension("lib")); - set.rewrite_path(args, "IMPLIB", &library)?; - set.stage(&library.with_extension("exp"))?; - let pdb = option_path(args, "PDB").unwrap_or_else(|| output.with_extension("pdb")); - if !pdb.to_str().is_some_and(|name| name.eq_ignore_ascii_case("NONE")) { - set.rewrite_path(args, "PDB", &pdb)?; - if !args.iter().any(|arg| option_value(arg, "PDBALTPATH").is_some()) { - let final_pdb = set.outputs.last().unwrap().destination.to_string_lossy(); - args.push(format!("/PDBALTPATH:{final_pdb}")); - } - } - for (option, default) in [ - ("ILK", output.with_extension("ilk")), - ("MAP", output.with_extension("map")), - ("MANIFESTFILE", PathBuf::from(format!("{}.manifest", output.display()))), - ] { - if args.iter().any(|arg| { + for (option, destination) in companions { + let explicit = args.iter().any(|arg| { option_value(arg, option).is_some() || arg.eq_ignore_ascii_case(&format!("/{option}")) - }) { - let destination = option_path(args, option).unwrap_or(default); + }); + if explicit || matches!(option, "IMPLIB" | "PDB") { set.rewrite_path(args, option, &destination)?; } else { - // Implicit companions remain beside the staged image. - set.stage(&default)?; + set.stage(&destination)?; + } + if option == "IMPLIB" { + set.stage(&destination.with_extension("exp"))?; + } + if option == "PDB" && !args.iter().any(|arg| option_value(arg, "PDBALTPATH").is_some()) + { + let final_pdb = set.outputs.last().unwrap().destination.to_string_lossy(); + args.push(format!("/PDBALTPATH:{final_pdb}")); } } Ok(set) @@ -213,6 +218,26 @@ impl MsvcOutputs { } } +fn companion_paths(output: &Path, args: &[String]) -> Vec<(&'static str, PathBuf)> { + [ + ("IMPLIB", output.with_extension("lib")), + ("PDB", output.with_extension("pdb")), + ("ILK", output.with_extension("ilk")), + ("MAP", output.with_extension("map")), + ("MANIFESTFILE", PathBuf::from(format!("{}.manifest", output.display()))), + ] + .into_iter() + .filter_map(|(option, default)| { + let path = option_path(args, option).unwrap_or(default); + if option == "PDB" && path.to_str().is_some_and(|s| s.eq_ignore_ascii_case("NONE")) { + None + } else { + Some((option, path)) + } + }) + .collect() +} + fn option_value<'a>(arg: &'a str, option: &str) -> Option<&'a str> { let (name, value) = arg.strip_prefix('/').or_else(|| arg.strip_prefix('-'))?.split_once(':')?; name.eq_ignore_ascii_case(option).then_some(value) @@ -247,6 +272,35 @@ mod tests { path } + #[test] + fn planned_destinations_match_staging_and_keep_final_names() { + let root = directory("plan"); + let image = root.join("answer.dll"); + let mut args = vec![ + format!("/OUT:{}", image.display()), + format!("/PDB:{}", root.join("debug.pdb").display()), + format!("/MAP:{}", root.join("symbols.map").display()), + ]; + let planned = MsvcOutputs::destinations(&image, &args); + let pending = MsvcOutputs::prepare(&image, &mut args).unwrap(); + assert_eq!(planned.len(), pending.outputs.len()); + for (plan, actual) in planned.iter().zip(&pending.outputs) { + assert_eq!( + plan.parent().unwrap().canonicalize().unwrap().join(plan.file_name().unwrap()), + actual.destination, + ); + } + for output in &pending.outputs { + fs::write(&output.staged, b"new artifact").unwrap(); + } + pending.commit().unwrap(); + for path in planned { + assert_eq!(fs::read(path).unwrap(), b"new artifact"); + } + assert_eq!(fs::read_dir(&root).unwrap().count(), 7); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn publication_error_restores_previously_replaced_companions() { let root = directory("rollback"); diff --git a/src/module_resolver.rs b/src/module_resolver.rs index e9014ea0..f722cee6 100644 --- a/src/module_resolver.rs +++ b/src/module_resolver.rs @@ -89,6 +89,34 @@ pub fn resolve_import_graph( }) } +/// AST dumps do not require dependencies to exist or exported names to bind. +/// Still retain every existing source opened by the normal import loader, even +/// when that source cannot be parsed, so a dump cannot replace an input. +pub(crate) fn existing_import_paths( + entry_path: &Path, + ast: Vec, + config: &ImportConfig, +) -> Vec { + let entry = canonical_key(entry_path); + let mut loaded = HashSet::from([entry.to_string_lossy().into_owned()]); + let mut pending = vec![(entry, ast)]; + while let Some((path, nodes)) = pending.pop() { + let base = path.parent().unwrap_or(Path::new(".")); + for node in nodes { + if let ASTNode::Statement(StatementNode::Import(import)) = node.unspanned() { + if let Ok(unit) = + local_import_unit_with_config(&import.path, &mut loaded, base, config) + { + pending.push((unit.abs_path, unit.ast)); + } + } + } + } + let mut paths: Vec<_> = loaded.into_iter().map(PathBuf::from).collect(); + paths.sort(); + paths +} + /// Removes the internal module hash prefix from names shown to Wave users. pub fn demangle_module_names(text: &str) -> String { let mut out = String::with_capacity(text.len()); diff --git a/src/runner.rs b/src/runner.rs index ca8b25ee..bc1bc8c8 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -357,6 +357,21 @@ pub fn wave_input_paths(path: &Path, dep: &DepFlags, llvm: &LlvmFlags) -> Vec Vec { + let raw = fs::read_to_string(path).unwrap_or_else(|error| { + eprintln!("cannot read '{}': {error}", path.display()); + process::exit(1); + }); + let mut lexer = Lexer::new_with_file(&raw, path.display().to_string()); + let tokens = lexer.tokenize().unwrap_or_else(|error| { + error.display_auto(); + process::exit(1); + }); + let ast = parse_wave_tokens_or_exit(path, &raw, &tokens); + let config = build_import_config(dep, target_condition_context_for_llvm(Some(llvm))); + crate::module_resolver::existing_import_paths(path, ast, &config) +} + struct SemanticSourceUnit { path: PathBuf, source: String, diff --git a/tests/release_stabilization.rs b/tests/release_stabilization.rs index 8e9500ee..ff8a6b7e 100644 --- a/tests/release_stabilization.rs +++ b/tests/release_stabilization.rs @@ -58,6 +58,114 @@ fn source() -> &'static str { "fun main() -> i32 { return 0; }\n" } +#[test] +fn ast_only_outputs_preserve_transitively_imported_sources() { + let case = Case::new(); + let leaf = "pub fun value() -> i32 { return 7; }\n"; + let middle = "pub import(\"./leaf.wave\")::{value};\n"; + case.write( + "main.wave", + "import(\"unavailable::module\")::{missing}; import(\"./middle.wave\")::{value}; fun main() -> i32 { return value(); }", + ); + case.write("middle.wave", middle); + let input = case.write("leaf.wave", leaf); + for (format, filename) in + [("sexpr", "main.ast"), ("wson", "main.ast.wson"), ("json", "main.ast.json")] + { + let output = case.0.join(filename); + fs::hard_link(&input, &output).unwrap(); + rejected( + case.command() + .args(["build", "main.wave", "--emit=ast", "--ast-format", format]) + .output() + .unwrap(), + "aliases compiler input", + ); + assert_eq!(fs::read_to_string(&input).unwrap(), leaf); + assert_eq!(fs::read_to_string(case.0.join("middle.wave")).unwrap(), middle); + fs::remove_file(output).unwrap(); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(&input, case.0.join("main.ast.json")).unwrap(); + rejected( + case.command() + .args(["build", "main.wave", "--emit=ast", "--ast-format=json"]) + .output() + .unwrap(), + "aliases compiler input", + ); + assert_eq!(fs::read_to_string(&input).unwrap(), leaf); + fs::remove_file(case.0.join("main.ast.json")).unwrap(); + } + success( + case.command() + .args(["build", "main.wave", "--emit=ast", "--ast-format=json"]) + .output() + .unwrap(), + ); +} + +#[test] +fn msvc_companions_cannot_replace_sources_objects_or_named_libraries() { + for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] { + if (target.starts_with("x86_64") && !cfg!(feature = "llvm-target-x86")) + || (target.starts_with("aarch64") && !cfg!(feature = "llvm-target-aarch64")) + { + continue; + } + for option in ["PDB", "IMPLIB", "MAP", "ILK", "MANIFESTFILE"] { + let case = Case::new(); + let input = case.write("main.wave", source()); + rejected( + case.command() + .args(["build", "main.wave", "--target", target, "-o", "app.exe", "-C"]) + .arg(format!("link-arg=/{option}:./main.wave")) + .output() + .unwrap(), + "aliases compiler input", + ); + assert_eq!(fs::read_to_string(input).unwrap(), source()); + assert!(!case.0.join("main.o").exists()); + } + let case = Case::new(); + let input = case.write("input.obj", "OBJECT SENTINEL"); + let alias = case.0.join("alias.pdb"); + fs::hard_link(&input, &alias).unwrap(); + rejected( + case.command() + .args(["build", "input.obj", "--target", target, "-o", "app.exe", "-C"]) + .arg(format!("link-arg=/PDB:{}", alias.display())) + .output() + .unwrap(), + "aliases compiler input", + ); + assert_eq!(fs::read_to_string(input).unwrap(), "OBJECT SENTINEL"); + let library = case.write("sdk/support.lib", "LIBRARY SENTINEL"); + rejected( + case.command() + .args([ + "build", + "input.obj", + "--target", + target, + "-o", + "app.exe", + "-L", + "sdk", + "--link", + "support", + "-C", + ]) + .arg(format!("link-arg=/PDB:{}", library.display())) + .output() + .unwrap(), + "aliases compiler input", + ); + assert_eq!(fs::read_to_string(library).unwrap(), "LIBRARY SENTINEL"); + } +} + #[test] fn output_aliases_preserve_all_input_bytes_before_any_emission() { let case = Case::new(); diff --git a/tools/check_case_sources.py b/tools/check_case_sources.py index 89e5f408..d5fae756 100644 --- a/tools/check_case_sources.py +++ b/tools/check_case_sources.py @@ -8,11 +8,15 @@ try: from tools.case_manifest import CASES_ROOT, ROOT, load_case_manifest - from tools.validation_reports import write_report as _write_report, validate_report_path + from tools.validation_reports import ( + write_report as _write_report, validate_report_path, case_report_inputs, + ) from tools.process_tree import run_process, timeout_output except ModuleNotFoundError: from case_manifest import CASES_ROOT, ROOT, load_case_manifest - from validation_reports import write_report as _write_report, validate_report_path + from validation_reports import ( + write_report as _write_report, validate_report_path, case_report_inputs, + ) from process_tree import run_process, timeout_output @@ -50,7 +54,9 @@ def check_sources(wavec, sources, report, timeout=15): sources = list(sources) report = Path(report) try: - validate_report_path(report, [Path(wavec), *(CASES_ROOT / name for name in sources)]) + validate_report_path( + report, case_report_inputs(ROOT, [Path(wavec), *(CASES_ROOT / name for name in sources)]) + ) except (OSError, ValueError) as error: print(error, file=sys.stderr) return 1 @@ -93,7 +99,10 @@ def main(argv=None): try: protected = (list(CASES_ROOT.rglob("*.wave")) if sources is None else [CASES_ROOT / name for name in sources]) - validate_report_path(args.report_json, [Path(args.wavec), ROOT / args.wavec, *protected]) + validate_report_path( + args.report_json, + case_report_inputs(ROOT, [Path(args.wavec), ROOT / args.wavec, *protected]), + ) _write_report(args.report_json, {"phase": "source-check", "error": str(error), "results": []}) except (ValueError, OSError) as report_error: print(report_error, file=sys.stderr) diff --git a/tools/ci/common.py b/tools/ci/common.py index f2b7fbe1..46bb4a02 100644 --- a/tools/ci/common.py +++ b/tools/ci/common.py @@ -92,6 +92,7 @@ def __init__( "src", "front", "llvm", + "whale", "utils", "tests", "std", diff --git a/tools/run_runtime_cases.py b/tools/run_runtime_cases.py index 9a141e8f..8495a0cf 100644 --- a/tools/run_runtime_cases.py +++ b/tools/run_runtime_cases.py @@ -14,7 +14,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from tools.case_manifest import load_case_manifest -from tools.validation_reports import write_report, validate_report_path +from tools.validation_reports import write_report, validate_report_path, case_report_inputs from tools.process_tree import run_process, timeout_output from tools.test_contracts import parse_test_metadata @@ -37,8 +37,12 @@ def excerpt(record, name, text): def execute(options): try: - validate_report_path(options.report_json, [options.wavec, - *(ROOT / "tests/cases" / name for name in options.sources)]) + validate_report_path( + options.report_json, + case_report_inputs(ROOT, [ + options.wavec, *(ROOT / "tests/cases" / name for name in options.sources), + ]), + ) except (OSError, ValueError) as error: print(error, file=sys.stderr) return 2 diff --git a/tools/run_tests.py b/tools/run_tests.py index 84999b34..e94eabe5 100644 --- a/tools/run_tests.py +++ b/tools/run_tests.py @@ -31,7 +31,7 @@ from dataclasses import replace try: - from tools.validation_reports import write_report, validate_report_path + from tools.validation_reports import write_report, validate_report_path, case_report_inputs from tools.process_tree import ProcessTree, run_process, timeout_output from tools.case_manifest import load_case_manifest from tools.test_contracts import ( @@ -40,7 +40,7 @@ validate_compiled_artifact, ) except ModuleNotFoundError: - from validation_reports import write_report, validate_report_path + from validation_reports import write_report, validate_report_path, case_report_inputs from process_tree import ProcessTree, run_process, timeout_output from case_manifest import load_case_manifest from test_contracts import ( @@ -635,7 +635,10 @@ def interrupted(signum, frame): if ARGS.report_json is not None: try: - validate_report_path(ARGS.report_json, [WAVEC, *(ROOT / path for _, path in entries)]) + validate_report_path( + ARGS.report_json, + case_report_inputs(ROOT, [WAVEC, *(ROOT / path for _, path in entries)]), + ) except (ValueError, OSError) as error: print(error, file=sys.stderr) sys.exit(2) diff --git a/tools/test_check_case_sources.py b/tools/test_check_case_sources.py index 037e0020..6d814df4 100644 --- a/tools/test_check_case_sources.py +++ b/tools/test_check_case_sources.py @@ -12,6 +12,43 @@ class SourceCheckTests(unittest.TestCase): + def test_reports_preserve_std_and_unselected_imports_on_preflight_failure(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cases = root / "tests/cases" + cases.mkdir(parents=True) + (cases / "main.wave").write_text('import("./helper")::{value}; fun main() {}') + helper = cases / "helper.wave" + helper.write_text("pub const value: i32 = 7;\n") + std = root / "std" + std.mkdir() + (std / "layout.wave").write_text("STD SENTINEL") + (std / "manifest.json").write_text("MANIFEST SENTINEL") + for source in (helper, std / "layout.wave", std / "manifest.json"): + expected = source.read_bytes() + for kind in ("direct", "hardlink", "symlink"): + with self.subTest(input=source.name, alias=kind): + report = root / f"{source.name}-{kind}" + if kind == "direct": + report = source + elif kind == "hardlink": + os.link(source, report) + else: + try: + report.symlink_to(source) + except OSError: + continue + with patch.object(checker, "ROOT", root), \ + patch.object(checker, "CASES_ROOT", cases), \ + patch.object(checker, "run_process") as run: + status = checker.check_sources("missing", ["main.wave"], report) + self.assertEqual(status, 1) + run.assert_not_called() + self.assertEqual(checker.main([ + "--wavec", "missing", "--report-json", str(report) + ]), 1) + self.assertEqual(source.read_bytes(), expected) + def test_checks_after_multiple_failures_and_keeps_diagnostics(self): with tempfile.TemporaryDirectory() as directory: report = Path(directory) / "report.json" diff --git a/tools/test_ci.py b/tools/test_ci.py index fbda1a20..5d9a334b 100644 --- a/tools/test_ci.py +++ b/tools/test_ci.py @@ -451,6 +451,35 @@ def test_report_cannot_replace_source_even_through_alias(self): with self.assertRaisesRegex(ValueError, "aliases an input"): common.Runner(targets.resolve("linux-amd64"), report) + def test_report_protects_whale_source_and_manifest_before_preflight(self): + checkout = self.root / "checkout" + source = checkout / "whale/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text("pub fn original() {}\n") + manifest = checkout / "whale/Cargo.toml" + manifest.write_text('[package]\nname="whale"\n') + for input_path in (source, manifest): + expected = input_path.read_bytes() + for kind in ("direct", "normalized", "hardlink", "symlink"): + with self.subTest(input=input_path.name, alias=kind): + alias = self.root / f"{input_path.name}-{kind}" + if kind == "direct": + alias = input_path + elif kind == "normalized": + alias = input_path.parent / "." / input_path.name + elif kind == "hardlink": + os.link(input_path, alias) + else: + try: + alias.symlink_to(input_path) + except OSError: + continue + with patch.object(common, "ROOT", checkout), self.assertRaisesRegex( + ValueError, "aliases an input" + ): + common.Runner(targets.resolve("linux-amd64"), alias) + self.assertEqual(input_path.read_bytes(), expected) + def test_std_setup_preserves_user_installation(self): home = self.root / "real home" installed = home / ".wave/lib/wave/std" diff --git a/tools/test_run_tests.py b/tools/test_run_tests.py index 633ca20d..f2adfe05 100644 --- a/tools/test_run_tests.py +++ b/tools/test_run_tests.py @@ -1,4 +1,5 @@ import io +import os import sys import json import subprocess @@ -13,6 +14,46 @@ from types import SimpleNamespace class TestRunTestsCLI(unittest.TestCase): + def test_report_cannot_replace_std_or_unselected_helper_before_compilation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + compiler = root / "wavec" + compiler.touch() + if os.name != "nt": + compiler.chmod(0o755) + for name in ("std/manifest.json", "tests/cases/helper.wave"): + source = root / name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("INPUT SENTINEL") + for kind in ("direct", "hardlink", "symlink"): + with self.subTest(input=name, alias=kind): + report = root / f"{source.parent.name}-{kind}" + if kind == "direct": + report = source + elif kind == "hardlink": + os.link(source, report) + else: + try: + report.symlink_to(source) + except OSError: + continue + with ( + patch.object(runner, "ROOT", root), + patch.object(runner, "resolve_wavec", return_value=compiler), + patch.object(runner, "iter_test_entries", return_value=[ + ("main", "tests/cases/main.wave"), + ]), + patch.object(runner, "run_and_classify") as run, + self.assertRaises(SystemExit) as error, + ): + main([ + "--wavec", str(compiler), "--suite", "shared", + "--report-json", str(report), + ]) + self.assertEqual(error.exception.code, 2) + run.assert_not_called() + self.assertEqual(source.read_text(), "INPUT SENTINEL") + def test_report_retains_failure_exit_bits_diagnostics_and_artifact_reason(self): scenarios = [(7, None), (0xc0000005, None), (-11, None), (0, "wrong object machine")] for code, artifact_error in scenarios: diff --git a/tools/test_runtime_cases.py b/tools/test_runtime_cases.py index e13321bf..251a2b0a 100644 --- a/tools/test_runtime_cases.py +++ b/tools/test_runtime_cases.py @@ -4,6 +4,7 @@ import contextlib import io import json +import os from pathlib import Path import subprocess import sys @@ -17,6 +18,37 @@ class RuntimeReportTests(unittest.TestCase): + def test_reports_do_not_clobber_std_or_unselected_imports_before_selection(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in ("std/mem/layout.wave", "tests/cases/helper.wave"): + source = root / name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("INPUT SENTINEL") + for kind in ("direct", "hardlink", "symlink"): + with self.subTest(input=name, alias=kind): + report = root / f"{source.parent.name}-{kind}" + if kind == "direct": + report = source + elif kind == "hardlink": + os.link(source, report) + else: + try: + report.symlink_to(source) + except OSError: + continue + options = SimpleNamespace( + wavec=Path(sys.executable), report_json=report, + sources=["main.wave"], target_id="not-a-target", + ) + with patch.object(runner, "ROOT", root), \ + patch.object(runner, "load_case_manifest") as manifest, \ + patch.object(runner, "run_process") as run: + self.assertEqual(runner.execute(options), 2) + manifest.assert_not_called() + run.assert_not_called() + self.assertEqual(source.read_text(), "INPUT SENTINEL") + def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources=None, alias_root=False, metadata="", plan_text=None): calls = [] self.process_inputs = [] diff --git a/tools/validation_reports.py b/tools/validation_reports.py index 3aef3aab..88b186df 100644 --- a/tools/validation_reports.py +++ b/tools/validation_reports.py @@ -6,6 +6,20 @@ import tempfile +def case_report_inputs(root, sources): + """Protect case imports and checkout std before even preflight saves. + + Local Wave imports are confined to their module directories. Protecting the + source tree also covers target-gated imports and malformed sources without + launching a compiler or guessing which branch of an import will be selected. + """ + root = Path(root) + inputs = list(sources) + inputs.extend(path for path in (root / "tests").rglob("*.wave") if path.is_file()) + inputs.extend(path for path in (root / "std").rglob("*") if path.is_file()) + return inputs + + def validate_report_path(report, inputs): report = Path(report) resolved = report.resolve() From 73aff59ea6ada94d07802995b412ad35b300c333 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Mon, 12 Oct 2026 17:18:57 +0900 Subject: [PATCH 2/2] fix: preserve Whale lowering semantics and add bounded oracle tests Update the official format-4 dependency and address #868, #900, #877, #907 and #869. Keep native Whale execution unsupported and the LLVM default unchanged. Signed-off-by: LunaStev --- Cargo.lock | 4 +- front/hir/src/conversions.rs | 59 +++++- front/parser/src/verification.rs | 19 ++ tests/whale_backend.rs | 59 +++++- whale/src/codegen/expression.rs | 16 +- whale/src/codegen/mod.rs | 7 + whale/src/codegen/numeric.rs | 2 +- whale/src/codegen/tests.rs | 3 + whale/src/codegen/tests/float_negation.rs | 114 +++++++++++ whale/src/codegen/tests/oracle.rs | 227 ++++++++++++++++++++++ 10 files changed, 499 insertions(+), 11 deletions(-) create mode 100644 whale/src/codegen/tests/float_negation.rs create mode 100644 whale/src/codegen/tests/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 965d2598..ccade7c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,7 +68,7 @@ dependencies = [ [[package]] name = "ir" version = "0.1.0" -source = "git+https://github.com/wavefnd/Whale.git?branch=master#020d2a4cf312123518ec75cc865eea1d257c5689" +source = "git+https://github.com/wavefnd/Whale.git?branch=master#06577d657a242ffdd60f07db5bd788d110fc66ab" dependencies = [ "whale-target", ] @@ -238,4 +238,4 @@ dependencies = [ [[package]] name = "whale-target" version = "0.1.0" -source = "git+https://github.com/wavefnd/Whale.git?branch=master#020d2a4cf312123518ec75cc865eea1d257c5689" +source = "git+https://github.com/wavefnd/Whale.git?branch=master#06577d657a242ffdd60f07db5bd788d110fc66ab" diff --git a/front/hir/src/conversions.rs b/front/hir/src/conversions.rs index 08d14373..de8687c8 100644 --- a/front/hir/src/conversions.rs +++ b/front/hir/src/conversions.rs @@ -236,7 +236,7 @@ impl Planner<'_> { match self.program.type_of(expr)? { HirExpressionType::Resolved(ty) if scalar(ty) => Some(ty.clone()), HirExpressionType::IntegerLiteral => Some( - hint.filter(|t| numeric(t)) + hint.filter(|t| numeric(t) && **t != WaveType::Bool) .cloned() .unwrap_or(WaveType::Int(32)), ), @@ -387,8 +387,8 @@ impl Planner<'_> { fact.shift_count_type = Some(right_type); } else if numeric(<) && numeric(&rt) { if matches!(operator, Operator::LogicalAnd | Operator::LogicalOr) { - self.plan(left, None, None); - self.plan(right, None, None); + self.plan(left, Some(WaveType::Bool), None); + self.plan(right, Some(WaveType::Bool), None); fact.evaluation_type = WaveType::Bool; fact.result_type = WaveType::Bool; fact.computation_type = Some(WaveType::Bool); @@ -512,7 +512,10 @@ pub(super) fn verify_expression( return Err("missing numeric computation type".into()); }; if matches!(operator, Operator::LogicalAnd | Operator::LogicalOr) { - if computation != &WaveType::Bool || fact.evaluation_type != WaveType::Bool + if computation != &WaveType::Bool + || fact.evaluation_type != WaveType::Bool + || left.result_type != WaveType::Bool + || right.result_type != WaveType::Bool { return Err("invalid logical computation".into()); } @@ -651,6 +654,54 @@ mod tests { ); } + #[test] + fn numeric_conditions_and_logical_operands_record_ordered_bool_conversions() { + use parser::ast::StatementNode; + let mut p = program( + r#" +fun check(n: i64, f: f64) { + if (n) {} else if (f) {} + while (n) {} + var b: bool = n && 2; +} +"#, + ); + p.verify_conversions().unwrap(); + let ASTNode::Function(function) = &p.syntax()[0] else { panic!() }; + let ASTNode::Statement(StatementNode::If { condition, else_if_blocks, .. }) = + &function.body[0] + else { + panic!() + }; + let integer = p.numeric_expression_of(condition).unwrap(); + assert_eq!(integer.evaluation_type, WaveType::Int(64)); + assert_eq!(integer.result_type, WaveType::Bool); + assert_eq!(integer.conversions[0].kind, ConversionKind::IntegerToBool); + assert_eq!(integer.conversions[0].mode, ConversionMode::Implicit); + assert_eq!(integer.conversions[0].span.file, "conversions.wave"); + let float = p.numeric_expression_of(&else_if_blocks.as_ref().unwrap()[0].0).unwrap(); + assert_eq!(float.evaluation_type, WaveType::Float(64)); + assert_eq!(float.conversions[0].kind, ConversionKind::FloatToBool); + let ASTNode::Statement(StatementNode::While { condition, .. }) = &function.body[1] else { + panic!() + }; + assert_eq!(p.numeric_expression_of(condition).unwrap().result_type, WaveType::Bool); + let Expression::BinaryExpression { left, right, .. } = initializer(&p, 2) else { panic!() }; + for operand in [left.as_ref(), right.as_ref()] { + let fact = p.numeric_expression_of(operand).unwrap(); + assert_eq!(fact.result_type, WaveType::Bool); + assert_eq!(fact.conversions[0].kind, ConversionKind::IntegerToBool); + } + // A connected but omitted bool conversion is still invalid at the + // logical operand boundary; a backend must never need to guess it. + let id = p.expression_id(left).unwrap(); + let fact = p.numeric_expressions[id.index()].as_mut().unwrap(); + fact.conversions.clear(); + fact.result_type = fact.evaluation_type.clone(); + fact.context_type = None; + assert!(p.verify_conversions().is_err()); + } + #[test] fn ordered_casts_preserve_truncation_signedness_modes_and_spans() { let source = "fun check(x: i32) { var y: i64 = x as u8 as i32; var z: u32 = x as u32; var identity: i32 = x as i32; }"; diff --git a/front/parser/src/verification.rs b/front/parser/src/verification.rs index 1f3ddff4..4bd20ec4 100644 --- a/front/parser/src/verification.rs +++ b/front/parser/src/verification.rs @@ -980,6 +980,25 @@ impl<'a> Validator<'a> { let ty = self.validate_expr(expression)?; if self.is_truthy_expression(&ty) { + // Record the required result without changing literal inference or + // introducing a pointer-to-bool cast into the scalar contract. + let numeric = match &ty { + ExpressionType::IntLiteral(_) | ExpressionType::FloatLiteral => true, + ExpressionType::Known(ty) => matches!( + self.program.canonical_type(ty), + WaveType::Bool + | WaveType::Int(_) + | WaveType::Uint(_) + | WaveType::Float(_) + | WaveType::Byte + | WaveType::Char + ), + _ => false, + }; + if numeric { + self.expected_types + .insert(expression as *const Expression as usize, WaveType::Bool); + } return Ok(()); } diff --git a/tests/whale_backend.rs b/tests/whale_backend.rs index 2a48b700..ca531200 100644 --- a/tests/whale_backend.rs +++ b/tests/whale_backend.rs @@ -61,7 +61,7 @@ fun main() -> i32 { "#, ); let ir = case.ir(); - assert!(ir.contains("format_version 3"), "{ir}"); + assert!(ir.contains("format_version 4"), "{ir}"); assert!(ir.contains("x86_64-whale-linux"), "{ir}"); assert!(ir.contains("phi"), "{ir}"); assert!(ir.contains("call"), "{ir}"); @@ -111,6 +111,63 @@ fun main() -> i32 { if (truth(0.0)) { return 1; } return 0; } assert!(ir.contains("0x8000000000000000"), "{ir}"); } +#[test] +fn numeric_conditions_and_logical_operands_produce_verified_boolean_ir() { + let case = Case::new( + "truthiness", + r#" +fun integer(n: i64, b: byte) -> bool { return n && b; } +fun either(n: u128, b: bool) -> bool { return n || b; } +fun floating(n: f64) -> i32 { + if ((n)) { return 7; } + else if (2) { return 3; } + else { return 9; } +} +fun main() -> i32 { + var n: i32 = 3; + while (n) { n = 0; } + return n; +} +"#, + ); + let ir = case.ir(); + assert!(ir.contains("fcmp une f64"), "{ir}"); + assert!(ir.contains("icmp ne i64"), "{ir}"); + assert!(ir.contains("phi bool"), "{ir}"); + for backend in [vec!["check", "main.wave"], vec!["--whale", "check", "main.wave"]] { + let out = case.run(&backend); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + } +} + +#[test] +fn void_calls_keep_effects_through_grouping_and_void_returns() { + let case = Case::new( + "void-effects", + r#" +fun noop() {} +fun grouped() { (((noop()))); } +fun returning() { return ((noop())); } +fun main() -> i32 { grouped(); returning(); return 0; } +"#, + ); + let ir = case.ir(); + // One call in each wrapper, plus the two wrapper calls in main. + assert_eq!(ir.lines().filter(|line| line.contains("call ")).count(), 4, "{ir}"); + assert!(ir.contains("ret void"), "{ir}"); + for source in [ + "fun noop() {} fun main() -> i32 { return noop(); }", + "fun noop() {} fun main() { var n: i32 = noop(); }", + ] { + fs::write(case.root.join("main.wave"), source).unwrap(); + let out = case.run(&["--whale", "check", "main.wave", "--error-format=json"]); + assert!(!out.status.success()); + let diagnostic = String::from_utf8_lossy(&out.stderr); + assert!(wson::parse_json(diagnostic.trim()).is_ok(), "{diagnostic}"); + assert!(!diagnostic.contains("internal compiler error"), "{diagnostic}"); + } +} + #[test] fn unsupported_source_has_json_location_and_preserves_previous_output() { let case = Case::new("unsupported", "fun main() -> i32 {\n return 8 / 2;\n}\n"); diff --git a/whale/src/codegen/expression.rs b/whale/src/codegen/expression.rs index 44daf1f7..3a107b3e 100644 --- a/whale/src/codegen/expression.rs +++ b/whale/src/codegen/expression.rs @@ -6,6 +6,10 @@ use utils::const_int::ConstInt; impl Lowerer<'_> { pub(super) fn effect(&mut self, e: &Expression) -> Result<()> { + let mut e = e; + while let Expression::Grouped(inner) = e.unspanned() { + e = inner; + } if let Expression::FunctionCall { name, args, .. } = e.unspanned() { self.call(name, args).map(|_| ()).map_err(|mut error| { if error.span.is_none() { @@ -46,9 +50,15 @@ impl Lowerer<'_> { let ty = scalar(&fact.evaluation_type)?; Ok(match operator { Operator::Neg if matches!(ty, Type::F32 | Type::F64) => { - // Multiplication by -1 preserves both signs of zero. - let minus_one = self.literal(&Literal::Float(-1.0), &fact.evaluation_type)?; - self.bin(BinOp::FMul, ty.clone(), operand, minus_one)? + // Unary minus flips the sign bit without quieting a signaling + // NaN or altering its payload. Floating multiplication cannot + // preserve that contract. + let bits = if ty == Type::F32 { 32 } else { 64 }; + let raw_ty = if bits == 32 { Type::U32 } else { Type::U64 }; + let raw = self.cast_value(CastOp::Bitcast, operand, raw_ty.clone()); + let sign = self.constant(raw_ty.clone(), ConstValue::U(1u128 << (bits - 1))); + let flipped = self.bin(BinOp::Xor, raw_ty, raw, sign)?; + self.cast_value(CastOp::Bitcast, flipped, ty) }, Operator::Neg => { let zero = self.literal(&Literal::Int("0".into()), &fact.evaluation_type)?; diff --git a/whale/src/codegen/mod.rs b/whale/src/codegen/mod.rs index c357ff8f..37c32344 100644 --- a/whale/src/codegen/mod.rs +++ b/whale/src/codegen/mod.rs @@ -283,6 +283,13 @@ impl<'a> Lowerer<'a> { self.store(&self.slot(variable)?, value) }, ASTNode::Statement(StatementNode::Return(expr)) => { + if self.function.ret_ty == Type::Void { + if let Some(expr) = expr { + self.effect(expr)?; + } + self.terminate(Terminator::Ret { ty: Type::Void, value: None }); + return Ok(()); + } let value = expr.as_ref().map(|e| self.expr(e)).transpose()?; if value.as_ref().map(|v| &v.ty).unwrap_or(&Type::Void) != &self.function.ret_ty { return Err(ice("return disagrees with HIR signature")); diff --git a/whale/src/codegen/numeric.rs b/whale/src/codegen/numeric.rs index 9202619e..e4f84f61 100644 --- a/whale/src/codegen/numeric.rs +++ b/whale/src/codegen/numeric.rs @@ -8,7 +8,7 @@ use parser::ast::{Literal, Operator}; use utils::const_int::ConstInt; impl Lowerer<'_> { - fn cast_value(&mut self, op: CastOp, input: Value, ty: Type) -> Value { + pub(super) fn cast_value(&mut self, op: CastOp, input: Value, ty: Type) -> Value { let value = self.value(ty.clone()); self.emit(Instruction::Cast { dst: value.id, diff --git a/whale/src/codegen/tests.rs b/whale/src/codegen/tests.rs index ada22bbb..3542cf35 100644 --- a/whale/src/codegen/tests.rs +++ b/whale/src/codegen/tests.rs @@ -2,6 +2,9 @@ use super::*; use utils::const_int::ConstInt; +mod float_negation; +mod oracle; + fn module(source: &str) -> Module { let tokens = lexer::Lexer::new_with_file(source, "numeric.wave").tokenize().unwrap(); let syntax = parser::parse_syntax_with_spans(&tokens).unwrap(); diff --git a/whale/src/codegen/tests/float_negation.rs b/whale/src/codegen/tests/float_negation.rs new file mode 100644 index 00000000..90738322 --- /dev/null +++ b/whale/src/codegen/tests/float_negation.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Check IEEE bit patterns with the upstream integer oracle. This executes a +//! bit-preserving integer-carrier projection, not Whale floating arithmetic. +use super::module; +use whale_ir::*; + +fn carrier(ty: &Type) -> Type { + match ty { + Type::F32 => Type::U32, + Type::F64 => Type::U64, + Type::Ptr(inner) => Type::ptr_to(carrier(inner)), + _ => ty.clone(), + } +} + +fn integer_carrier(mut ir: Module) -> Module { + for declaration in &mut ir.declarations { + declaration.signature.ret = carrier(&declaration.signature.ret); + for ty in &mut declaration.signature.params { + *ty = carrier(ty); + } + } + for function in &mut ir.functions { + function.ret_ty = carrier(&function.ret_ty); + for parameter in &mut function.params { + parameter.ty = carrier(¶meter.ty); + } + for (_, ty) in &mut function.value_types { + *ty = carrier(ty); + } + for block in &mut function.blocks { + for instruction in &mut block.instructions { + match instruction { + Instruction::Alloca { ty, .. } + | Instruction::Store { ty, .. } + | Instruction::Load { ty, .. } => *ty = carrier(ty), + Instruction::Cast { op: CastOp::Bitcast, src_ty, dst_ty, .. } => { + *src_ty = carrier(src_ty); + *dst_ty = carrier(dst_ty); + }, + Instruction::Const { ty, value: ConstValue::U(_), .. } + | Instruction::Bin { op: BinOp::Xor, ty, .. } => *ty = carrier(ty), + other => panic!("not a bit-preserving operation: {other:?}"), + } + } + let Some(Terminator::Ret { ty, .. }) = &mut block.terminator else { + panic!("negation probe must return its bits") + }; + *ty = carrier(ty); + } + } + verify_module(&ir).unwrap(); + ir +} + +#[test] +fn floating_negation_flips_only_the_sign_bit_and_double_negation_restores_payloads() { + for (bits, patterns) in [ + ( + 32, + vec![ + 0, 0x80000000, 1, 0x80000001, 0x3f800000, 0x7f7fffff, 0x7f800000, 0xff800000, + 0x7f800001, 0xff800001, 0x7fc01234, 0xffc01234, + ], + ), + ( + 64, + vec![ + 0, + 0x8000000000000000, + 1, + 0x8000000000000001, + 0x3ff0000000000000, + 0x7fefffffffffffff, + 0x7ff0000000000000, + 0xfff0000000000000, + 0x7ff0000000000001, + 0xfff0000000000001, + 0x7ff8000000001234, + 0xfff8000000001234, + ], + ), + ] { + for (expression, sign) in [("-((n))", 1u128 << (bits - 1)), ("-(-n)", 0)] { + let verified = + module(&format!("fun probe(n: f{bits}) -> f{bits} {{ return {expression}; }}")); + let ir = integer_carrier(verified); + for pattern in &patterns { + let options = InterpreterOptions { + max_steps: 100, + ir_limits: IrLimits { max_nodes: 1000, ..IrLimits::default() }, + memory_limits: MemoryLimits { + max_bytes: 64, + max_allocations: 4, + max_pointer_fragments: 4, + max_work: 1000, + }, + }; + let result = interpret_with_options( + &ir, + ir.functions[0].id, + &[ConstValue::U(*pattern)], + options, + ) + .unwrap(); + assert_eq!( + result.value, + Some(ConstValue::U(pattern ^ sign)), + "f{bits} {expression}({pattern:#x})" + ); + } + } + } +} diff --git a/whale/src/codegen/tests/oracle.rs b/whale/src/codegen/tests/oracle.rs new file mode 100644 index 00000000..389a252b --- /dev/null +++ b/whale/src/codegen/tests/oracle.rs @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Execute the supported integer subset with the upstream bounded interpreter. +//! Float arithmetic and calls are explicit unsupported outcomes, not successes. +use super::{mask, module}; +use whale_ir::*; + +fn options() -> InterpreterOptions { + InterpreterOptions { + max_steps: 10_000, + ir_limits: IrLimits { max_nodes: 20_000, ..IrLimits::default() }, + memory_limits: MemoryLimits { + max_bytes: 4096, + max_allocations: 128, + max_pointer_fragments: 128, + max_work: 100_000, + }, + } +} + +fn run(module: &Module, arguments: &[ConstValue]) -> Result { + interpret_with_options(module, module.functions[0].id, arguments, options()) +} + +fn integer(raw: u128, bits: u32, signed: bool) -> ConstValue { + let raw = raw & mask(bits); + if signed { + let shift = 128 - bits; + ConstValue::I(((raw << shift) as i128) >> shift) + } else { + ConstValue::U(raw) + } +} + +#[test] +fn wrapping_arithmetic_executes_at_every_supported_integer_width() { + for bits in [8, 16, 32, 64, 128] { + for signed in [false, true] { + let ty = format!("{}{bits}", if signed { "i" } else { "u" }); + let ir = module(&format!( + "fun probe(a: {ty}, b: {ty}) -> {ty} {{ return (a + b) * b - b; }}" + )); + for (a, b) in + [(0, 1), (mask(bits), 2), (1 << (bits - 1), 3), (mask(bits) - 1, mask(bits))] + { + let expected = a.wrapping_add(b).wrapping_mul(b).wrapping_sub(b); + let result = + run(&ir, &[integer(a, bits, signed), integer(b, bits, signed)]).unwrap(); + assert_eq!(result.value, Some(integer(expected, bits, signed)), "{ty}: {a}, {b}"); + assert!(result.steps > 0 && result.steps < options().max_steps); + } + } + } +} + +#[test] +fn ordered_truncation_extension_and_equal_width_signedness_are_observable() { + let ir = module("fun probe(n: i32) -> i32 { return (n as u8) as i32; }"); + for n in [-257, -1, 0, 255, 256, 257, i32::MAX] { + assert_eq!( + run(&ir, &[ConstValue::I(n as i128)]).unwrap().value, + Some(ConstValue::I((n as u8) as i128)) + ); + } + let ir = module("fun probe(n: u32) -> i32 { return n as i32; }"); + for n in [0, 1, 0x80000000, u32::MAX] { + assert_eq!( + run(&ir, &[ConstValue::U(n as u128)]).unwrap().value, + Some(ConstValue::I((n as i32) as i128)) + ); + } + let ir = module("fun probe(n: i8) -> u64 { return n as u64; }"); + assert_eq!( + run(&ir, &[ConstValue::I(-1)]).unwrap().value, + Some(ConstValue::U(u64::MAX as u128)) + ); +} + +#[test] +fn shifts_execute_or_trap_on_original_counts_before_narrowing() { + for bits in [8, 16, 32, 64, 128] { + for signed in [false, true] { + let ty = format!("{}{bits}", if signed { "i" } else { "u" }); + let raw = mask(bits) - 1; + for count_bits in [8, 16, 32, 64, 128] { + for count_signed in [false, true] { + let rhs = format!("{}{count_bits}", if count_signed { "i" } else { "u" }); + for op in ["<<", ">>"] { + let ir = module(&format!( + "fun probe(x: {ty}, n: {rhs}) -> {ty} {{ return x {op} n; }}" + )); + for count in + [0, 1, bits as u128 - 1, bits as u128, bits as u128 + 1, 256, u128::MAX] + { + let count = count & mask(count_bits); + let negative = count_signed && count & (1 << (count_bits - 1)) != 0; + let result = run( + &ir, + &[ + integer(raw, bits, signed), + integer(count, count_bits, count_signed), + ], + ); + if negative || count >= bits as u128 { + assert!( + matches!(result, Err(InterpreterError::Trap(InterpreterTrap { + reason: TrapReason::Explicit(ref reason), .. + })) if reason == "shift count out of range"), + "{ty} {op} {rhs}({count}): {result:?}" + ); + } else { + let shift = count as u32; + let expected = if op == "<<" { + raw << shift + } else if signed { + let ConstValue::I(n) = integer(raw, bits, true) else { + unreachable!() + }; + (n >> shift) as u128 + } else { + raw >> shift + }; + assert_eq!( + result.unwrap().value, + Some(integer(expected, bits, signed)), + "{ty} {op} {rhs}({count})" + ); + } + } + } + } + } + } + } +} + +#[test] +fn logical_conversions_short_circuit_traps_and_loop_storage_stays_initialized() { + let ir = + module("fun probe(flag: i32, n: u64) -> bool { return flag && (((1 as u8) << n) != 0); }"); + assert_eq!( + run(&ir, &[ConstValue::I(0), ConstValue::U(8)]).unwrap().value, + Some(ConstValue::Bool(false)) + ); + assert_eq!( + run(&ir, &[ConstValue::I(-1), ConstValue::U(7)]).unwrap().value, + Some(ConstValue::Bool(true)) + ); + assert!(matches!( + run(&ir, &[ConstValue::I(1), ConstValue::U(8)]), + Err(InterpreterError::Trap(_)) + )); + let ir = + module("fun probe(flag: i32, n: u64) -> bool { return flag || (((1 as u8) << n) != 0); }"); + assert_eq!( + run(&ir, &[ConstValue::I(3), ConstValue::U(8)]).unwrap().value, + Some(ConstValue::Bool(true)) + ); + let ir = module( + r#" +fun probe(limit: i32) -> i32 { + var n: i32 = 0; + var sum: i32 = 0; + while (n < limit) { + n = n + 1; + if (n == 2) { continue; } + if (n == 5) { break; } + var sum: i32 = sum + 10; + if (sum < 10) { return -1; } + } + return sum + n; +} +"#, + ); + for (limit, expected) in [(0, 0), (1, 1), (3, 3), (6, 5)] { + assert_eq!(run(&ir, &[ConstValue::I(limit)]).unwrap().value, Some(ConstValue::I(expected))); + } +} + +#[test] +fn numeric_condition_truthiness_executes_without_reinferring_hir_types() { + for ty in ["i8", "i32", "i64", "i128", "u8", "u64", "u128", "byte"] { + let signed = ty.starts_with('i'); + let ir = module(&format!( + "fun probe(n: {ty}) -> i32 {{ if ((n)) {{ return 7; }} else if (2) {{ return 3; }} else {{ return 9; }} }}" + )); + for n in [0, 1, 2] { + let arg = if signed { ConstValue::I(n) } else { ConstValue::U(n as u128) }; + assert_eq!( + run(&ir, &[arg]).unwrap().value, + Some(ConstValue::I(if n == 0 { 3 } else { 7 })) + ); + } + } + let ir = module("fun probe(n: i32) -> i32 { var x: i32 = n; while (x) { x = 0; } return x; }"); + assert_eq!(run(&ir, &[ConstValue::I(-3)]).unwrap().value, Some(ConstValue::I(0))); +} + +#[test] +fn budgets_unsupported_operations_and_traps_have_distinct_outcomes() { + let ir = module( + "fun probe(n: i32) -> i32 { var x: i32 = n; while (x >= 0) { x = x + 1; } return x; }", + ); + let limited = InterpreterOptions { max_steps: 40, ..options() }; + assert!(matches!( + interpret_with_options(&ir, ir.functions[0].id, &[ConstValue::I(0)], limited), + Err(InterpreterError::StepLimit { limit: 40, .. }) + )); + let ir = module("fun probe(n: i64) -> i64 { var x: i64 = n; return x; }"); + let limited = InterpreterOptions { + memory_limits: MemoryLimits { max_bytes: 1, ..options().memory_limits }, + ..options() + }; + assert!(matches!( + interpret_with_options(&ir, ir.functions[0].id, &[ConstValue::I(0)], limited), + Err(InterpreterError::MemoryLimit { .. }) + )); + let ir = module("fun probe(n: i32) -> i32 { return helper(n); } fun helper(n: i32) -> i32 { return n + 1; }"); + assert!(matches!( + run(&ir, &[ConstValue::I(0)]), + Err(InterpreterError::UnsupportedInstruction { .. }) + )); + let ir = module("fun probe(n: i32) -> i32 { var f: f64 = n as f64; return f as i32; }"); + assert!(matches!( + run(&ir, &[ConstValue::I(0)]), + Err(InterpreterError::UnsupportedInstruction { .. }) + )); +}