From 6b8671217be04600c9cb8e91c0166261664cd28a Mon Sep 17 00:00:00 2001 From: LunaStev Date: Thu, 1 Oct 2026 12:37:19 +0900 Subject: [PATCH] Harden validation, cleanup, and release asset checks Signed-off-by: LunaStev --- .github/workflows/release.yml | 29 +++----- .github/workflows/rust.yml | 14 ++-- src/cli.rs | 2 +- tests/codegen_regressions.rs | 41 ++++++++++++ tools/check_freebsd_sys.py | 37 +++++++++-- tools/check_release_assets.py | 74 +++++++++++++++++++++ tools/run_runtime_cases.py | 88 ++++++++++++++++++------- tools/test_freebsd_runtime_reporting.py | 56 ++++++++++++++++ tools/test_release_publish.py | 76 ++++++++++++++++++++- tools/test_runtime_cases.py | 77 +++++++++++++++++++--- tools/test_x.py | 60 +++++++++++++++++ x.py | 22 +++++-- 12 files changed, 500 insertions(+), 76 deletions(-) create mode 100644 tools/check_release_assets.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bad1a15..98e22b3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,7 +172,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py - python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_release_publish + python3 -m unittest tools.test_x tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_release_publish - name: Run Rust tests run: cargo test --locked --all-targets --verbose @@ -913,6 +913,12 @@ jobs: contents: write steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/download-artifact@v4 with: pattern: release-* @@ -920,26 +926,7 @@ jobs: merge-multiple: true - name: Verify release assets - shell: bash - working-directory: release-assets - run: | - set -euo pipefail - - test -f "wave-v${RELEASE_VERSION}-x86_64-linux-gnu.tar.gz" - test -f "wave-v${RELEASE_VERSION}-aarch64-linux-gnu.tar.gz" - test -f "wave-v${RELEASE_VERSION}-riscv64-linux-gnu.tar.gz" - test -f "wave-v${RELEASE_VERSION}-loongarch64-linux-gnu.tar.gz" - test -f "wave-v${RELEASE_VERSION}-x86_64-pc-windows-msvc.zip" - test -f "wave-v${RELEASE_VERSION}-aarch64-pc-windows-msvc.zip" - test -f "wave-v${RELEASE_VERSION}-aarch64-apple-darwin.tar.gz" - test -f "wave-v${RELEASE_VERSION}-x86_64-apple-darwin.tar.gz" - - for archive in *.tar.gz *.zip; do - test -f "$archive.sha256" - done - - cat ./*.sha256 > SHA256SUMS - sha256sum --check SHA256SUMS + run: python3 -m tools.check_release_assets --directory release-assets --version "$RELEASE_VERSION" - name: Create GitHub release shell: bash diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b4cd03dc..bdabe84c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -76,7 +76,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native tools.test_diagnose_windows_arm64 tools.test_runtime_selection + python3 -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native tools.test_diagnose_windows_arm64 tools.test_runtime_selection - name: Test Windows ARM64 dependency archive validation shell: pwsh @@ -145,7 +145,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -352,7 +352,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler id: release_build @@ -517,7 +517,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -587,7 +587,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -707,7 +707,7 @@ jobs: - name: Validate Windows Python tooling if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} run: >- - python -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution + python -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Check PE dependency failures and case runner selection if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} @@ -839,7 +839,7 @@ jobs: - name: Check native Windows process supervision if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} - run: python -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_process_tree + run: python -m unittest tools.test_x tools.test_release_publish tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_process_tree - name: Run native ARM64 unit and frontend driver tests if: ${{ !cancelled() && steps.native_build.outcome == 'success' }} diff --git a/src/cli.rs b/src/cli.rs index 7c30a5b9..ca4649b9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2668,7 +2668,7 @@ const wasi = new WASI({ }); const module = await WebAssembly.compile(await readFile(modulePath)); const instance = await WebAssembly.instantiate(module, wasi.getImportObject()); -wasi.start(instance); +process.exitCode = wasi.start(instance); "#; fn build_execute_command( diff --git a/tests/codegen_regressions.rs b/tests/codegen_regressions.rs index 5ec240d1..463166f1 100644 --- a/tests/codegen_regressions.rs +++ b/tests/codegen_regressions.rs @@ -3085,6 +3085,47 @@ fun main() -> i32 { ); } +#[test] +#[cfg(feature = "llvm-target-wasm")] +fn wasi_runner_preserves_explicit_process_exit_status() { + for tool in ["node", "wasm-ld"] { + if !Command::new(tool) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + { + eprintln!("skipping WASI execution: {tool} unavailable"); + return; + } + } + let dir = temp_case_dir("wasi-exit-status"); + for status in [0, 7] { + let source = write_wave( + &dir, + "exit.wave", + &format!( + "extern(c, \"proc_exit\") fun exit(code: u32);\n\ + fun main() -> i32 {{ exit({status}); return 0; }}\n" + ), + ); + let output = wavec_command() + .arg("build") + .arg(&source) + .args(["--target", "wasm32-wasip1", "--run", "--out-dir"]) + .arg(dir.join("out")) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(status), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + fs::remove_dir_all(dir).unwrap(); +} + #[test] #[cfg(feature = "llvm-target-wasm")] fn webassembly_c_abi_and_wasi_import_contracts_are_explicit() { diff --git a/tools/check_freebsd_sys.py b/tools/check_freebsd_sys.py index 7fc73d24..442c8a98 100644 --- a/tools/check_freebsd_sys.py +++ b/tools/check_freebsd_sys.py @@ -8,15 +8,20 @@ """ import argparse -import json import os from pathlib import Path import re import select +import shutil import subprocess +import sys import tempfile import time +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from tools.validation_reports import validate_report_path, write_report + ROOT = Path(__file__).resolve().parents[1] ARCHES = {"amd64": "x86_64", "arm64": "aarch64", "riscv64": "riscv64"} # Kernel device messages can interleave with init's prompt (even inside @@ -101,22 +106,44 @@ def main(): parser.add_argument("--case-timeout", type=int, default=60) args = parser.parse_args() report_path = args.report_json or args.out_dir / "report.json" - report_path.parent.mkdir(parents=True, exist_ok=True) + # Guard failure reports too, before creating directories or launching tools. + try: + protected = report_inputs(args) + validate_report_path(report_path, protected) + except (OSError, ValueError) as error: + print(f"Unsafe report destination: {error}", file=sys.stderr) + return 1 report = {"schema_version": 1, "arch": args.arch, "status": "running", "phase": "validation", "commands": [], "cases": []} + status = 0 try: if min(args.command_timeout, args.boot_timeout, args.case_timeout) <= 0: raise ValueError("timeouts must be positive") execute(args, report) report["status"] = "pass" - return 0 except (Exception, KeyboardInterrupt) as error: report["status"] = "interrupted" if isinstance(error, KeyboardInterrupt) else "fail" report["error"] = f"{type(error).__name__}: {error}" print(report["error"], flush=True) + status = 1 + try: + validate_report_path(report_path, protected) + write_report(report_path, report) + except (OSError, ValueError) as error: + print(f"Failed to write report: {error}", file=sys.stderr) return 1 - finally: - report_path.write_text(json.dumps(report, indent=2) + "\n") + return status + + +def report_inputs(args): + inputs = [p for p in (args.image, args.compiler, args.firmware, args.kernel) if p is not None] + # Include imported helper modules and std metadata, not just case entry points. + inputs.extend((ROOT / "tests/cases").rglob("*.wave")) + for directory in (ROOT / "std", ROOT / "tests/fixtures/freebsd_case_runtime"): + inputs.extend(p for p in directory.rglob("*") if p.is_file()) + for tool in (args.clang, args.linker, "genisoimage", "qemu-img", f"qemu-system-{ARCHES[args.arch]}"): + inputs.append(Path(shutil.which(tool) or tool)) + return inputs def discover_cases(suite): diff --git a/tools/check_release_assets.py b/tools/check_release_assets.py new file mode 100644 index 00000000..20592063 --- /dev/null +++ b/tools/check_release_assets.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Verify complete archive/checksum coverage before producing SHA256SUMS.""" +import argparse +import hashlib +import os +from pathlib import Path +import re +import sys +import tempfile + + +ARCHIVE_TARGETS = ( + "x86_64-linux-gnu", "aarch64-linux-gnu", "riscv64-linux-gnu", "loongarch64-linux-gnu", + "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc", + "aarch64-apple-darwin", "x86_64-apple-darwin", +) + + +def verify(directory, version): + directory = Path(directory) + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version): + raise ValueError(f"invalid release version: {version!r}") + expected = {f"wave-v{version}-{target}" + (".zip" if "windows" in target else ".tar.gz") + for target in ARCHIVE_TARGETS} + archives = {p.name for p in directory.iterdir() if p.name.endswith((".tar.gz", ".zip"))} + sidecars = {p.name for p in directory.iterdir() if p.name.endswith(".sha256")} + for label, actual, required in (("archives", archives, expected), + ("checksum files", sidecars, {name + ".sha256" for name in expected})): + if actual != required: + raise ValueError(f"release {label}: missing={sorted(required - actual)}, unexpected={sorted(actual - required)}") + records = [] + for name in sorted(expected): + archive, sidecar = directory / name, directory / (name + ".sha256") + if archive.is_symlink() or sidecar.is_symlink() or not archive.is_file() or not sidecar.is_file(): + raise ValueError(f"archive/checksum must be regular files: {name}") + lines = sidecar.read_text(encoding="ascii").splitlines() + match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *](.+)", lines[0]) if len(lines) == 1 else None + if match is None or match[2] != name: + raise ValueError(f"{sidecar.name}: expected exactly one checksum record naming {name}") + digest = hashlib.sha256() + with archive.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest() != match[1].lower(): + raise ValueError(f"checksum mismatch: {name}") + records.append(f"{digest.hexdigest()} {name}\n") + # Never publish a partial manifest or follow an existing manifest symlink. + fd, temporary = tempfile.mkstemp(prefix=".SHA256SUMS-", dir=directory) + try: + with os.fdopen(fd, "w", encoding="ascii", newline="\n") as stream: + stream.writelines(records) + os.replace(temporary, directory / "SHA256SUMS") + finally: + Path(temporary).unlink(missing_ok=True) + return len(records) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--version", required=True) + options = parser.parse_args(argv) + try: + count = verify(options.directory, options.version) + except (OSError, ValueError) as error: + print(f"Release asset validation failed: {error}", file=sys.stderr) + return 1 + print(f"Verified {count} release archives and checksum records") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_runtime_cases.py b/tools/run_runtime_cases.py index 12b87306..9a141e8f 100644 --- a/tools/run_runtime_cases.py +++ b/tools/run_runtime_cases.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: MPL-2.0 """Execute an already selected QEMU or WebAssembly suite and preserve evidence.""" import argparse +import json import math from pathlib import Path import platform @@ -15,6 +16,7 @@ from tools.case_manifest import load_case_manifest from tools.validation_reports import write_report, validate_report_path from tools.process_tree import run_process, timeout_output +from tools.test_contracts import parse_test_metadata ROOT = Path(__file__).resolve().parent.parent @@ -53,20 +55,20 @@ def save(): for status in ("pass", "fail", "timeout", "interrupted", "not_run")} write_report(options.report_json, report) - def command(row, args, phase, timeout): + def command(row, args, phase, timeout, expected_exit=0, stdin=None): args = list(map(str, args)) - record = {"command": args, "phase": phase, "expected_exit": 0, "status": "running"} + record = {"command": args, "phase": phase, "expected_exit": expected_exit, "status": "running"} row["commands"].append(record) save() try: result = run_process(args, cwd=ROOT, timeout=timeout, capture_output=True, - text=True, errors="replace") + text=True, errors="replace", input=stdin) record["actual_exit"] = result.returncode - record["status"] = "pass" if result.returncode == 0 else "fail" + record["status"] = "pass" if result.returncode == expected_exit else "fail" excerpt(record, "stdout", result.stdout) excerpt(record, "stderr", result.stderr) - if result.returncode: - record["reason"] = f"{phase} exited {result.returncode}, expected 0" + if result.returncode != expected_exit: + record["reason"] = f"{phase} exited {result.returncode}, expected {expected_exit}" except subprocess.TimeoutExpired as error: record.update(status="timeout", timeout_seconds=timeout, reason=f"{phase} timed out after {timeout}s") @@ -81,8 +83,8 @@ def command(row, args, phase, timeout): save() if record["status"] != "pass": row.update(status=record["status"], reason=record["reason"]) - return False - return True + return None + return result try: save() @@ -102,27 +104,46 @@ def command(row, args, phase, timeout): source = (cases_root / name).resolve() if Path(name).is_absolute() or ".." in Path(name).parts or not source.is_relative_to(cases_root) or not source.is_file(): raise ValueError(f"invalid selected runtime source: {name}") - sources.append(source) + metadata = parse_test_metadata(source) + if metadata.mode != "run" or metadata.runner != "native" or metadata.udp_input: + raise ValueError(f"unsupported runtime metadata for {name}: requires native run mode without udp-input") + sources.append((source, metadata)) with tempfile.TemporaryDirectory(prefix="wave-runtime-cases-") as temporary: - for index, (row, source) in enumerate(zip(report["tests"], sources)): + for index, (row, (source, metadata)) in enumerate(zip(report["tests"], sources)): print(f"RUN {row['name']} ({target.executor})", flush=True) compiler = str(options.wavec.resolve()) + output = Path(temporary) / str(index) + output.mkdir() + executable = output / ("case.wasm" if target.executor == "wasm" else "case") + build_args = [source, "--std-root", ROOT / "std", "--target", target.target, + "--out-dir", output, "-o", executable] if target.executor == "qemu": - output = Path(temporary) / str(index) - output.mkdir() - executable = output / "case" - built = command(row, [compiler, "build", source, "--std-root", ROOT / "std", "--target", target.target, - "--out-dir", output, "-o", executable], "build", options.build_timeout) - if built and not executable.is_file(): - row.update(status="fail", reason="build succeeded without producing an executable") - built = False - passed = built and command(row, [options.qemu, "-L", options.sysroot, executable], - "run", options.timeout) - else: - # wavec owns the target-specific JS/WASI host invocation. - # This command includes compilation; it is never labelled a compile-only pass. - passed = command(row, [compiler, "run", source, "--std-root", ROOT / "std", "--target", target.target], - "build-and-run", options.build_timeout + options.timeout) + build_args.extend(["--sysroot", options.sysroot.resolve()]) + built = command(row, [compiler, "build", *build_args], "build", options.build_timeout) + if built is not None and not executable.is_file(): + row.update(status="fail", reason="build succeeded without producing an executable") + built = None + passed = None + if built is not None: + runtime = None + if target.executor == "qemu": + runtime = [options.qemu, "-L", options.sysroot.resolve(), executable] + if target.executor == "wasm": + # Ask wavec for its existing host command; do not duplicate + # JS hosts or add imports here. Run only the built module. + planned = command(row, [compiler, "build", *build_args, "--run", "--dry-run", "--error-format=json"], + "plan", options.build_timeout) + runtime = None + if planned is not None: + try: + runtime = wasm_execute_plan(planned.stdout, executable) + except (ValueError, TypeError, KeyError) as error: + reason = f"invalid WebAssembly execution plan: {error}" + row.update(status="fail", reason=reason) + row["commands"][-1].update(status="fail", reason=reason) + if runtime is not None: + stdin = f"{metadata.stdin}\n" if metadata.stdin is not None else None + passed = command(row, runtime, "run", options.timeout, metadata.expected_exit, stdin) if passed: row["status"] = "pass" row.pop("reason", None) @@ -140,6 +161,23 @@ def command(row, args, phase, timeout): save() +def wasm_execute_plan(stdout, executable): + plan = json.loads(stdout) + if not isinstance(plan, dict) or not isinstance(plan.get("link"), dict): + raise ValueError("missing linked module") + if plan["link"].get("output") != str(executable): + raise ValueError("linked module differs from the built output") + execute = plan.get("execute") + if not isinstance(execute, dict): + raise ValueError("missing host command") + program, args = execute.get("program"), execute.get("args") + if not isinstance(program, str) or not program or not isinstance(args, list) or not all(isinstance(a, str) for a in args): + raise ValueError("host command must contain a program and string arguments") + if str(executable) not in args: + raise ValueError("host command does not reference the built module") + return [program, *args] + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--wavec", type=Path, required=True) diff --git a/tools/test_freebsd_runtime_reporting.py b/tools/test_freebsd_runtime_reporting.py index 5d8c2bec..835a4738 100644 --- a/tools/test_freebsd_runtime_reporting.py +++ b/tools/test_freebsd_runtime_reporting.py @@ -184,5 +184,61 @@ def test_missing_image_still_writes_machine_readable_failure(self): self.assertIn("File does not exist", report["error"]) + def call_main(self, report, *extra): + argv = ["runner", "--arch", "amd64", "--image", str(self.image), + "--compiler", str(self.compiler), "--out-dir", str(self.args.out_dir), + "--report-json", str(report), *map(str, extra)] + with patch.object(sys, "argv", argv), patch.object(runner, "ROOT", self.root): + return runner.main() + + def test_reports_cannot_alias_inputs_even_when_validation_would_fail(self): + paths = [self.image, self.compiler, self.root / "firmware", self.root / "kernel", + self.root / "clang", self.root / "linker", + self.root / "tests/cases/freebsd/amd64/test1.wave", + self.root / "tests/cases/freebsd/amd64/test3/helper.wave", + self.root / "tests/fixtures/freebsd_case_runtime/start.c", + self.root / "std/manifest.json", self.root / "std/io/fd.wave"] + extra = ["--firmware", paths[2], "--kernel", paths[3], "--clang", paths[4], "--linker", paths[5]] + for index, source in enumerate(paths): + source.parent.mkdir(parents=True, exist_ok=True) + sentinel = f"input {index}".encode() + source.write_bytes(sentinel) + for alias_kind in ("direct", "dot", "symlink", "hardlink"): + with self.subTest(source=source.name, alias=alias_kind): + alias = source + if alias_kind == "dot": + (source.parent / "empty").mkdir(exist_ok=True) + alias = source.parent / "empty/.." / source.name + elif alias_kind in ("symlink", "hardlink"): + alias = self.root / f"{index}-{alias_kind}" + try: + if alias_kind == "symlink": + alias.symlink_to(source) + else: + alias.hardlink_to(source) + except OSError: + continue # Some Windows runners do not permit links. + with patch.object(runner, "execute", side_effect=ValueError("preflight failed")) as execute: + self.assertEqual(self.call_main(alias, *extra), 1) + execute.assert_not_called() + self.assertEqual(source.read_bytes(), sentinel) + self.assertEqual(alias.read_bytes(), sentinel) + + def test_safe_reports_survive_failure_and_interruption(self): + report = self.root / "reports/result.json" + for error, expected in [(ValueError("preflight failed"), "fail"), + (KeyboardInterrupt(), "interrupted")]: + with self.subTest(expected=expected), patch.object(runner, "execute", side_effect=error): + self.assertEqual(self.call_main(report), 1) + self.assertEqual(json.loads(report.read_text())["status"], expected) + self.assertEqual(list(report.parent.glob("*.tmp")), []) + + def test_report_write_failure_returns_failure(self): + report = self.root / "report-directory" + report.mkdir() + with patch.object(runner, "execute"): + self.assertEqual(self.call_main(report), 1) + + if __name__ == "__main__": unittest.main() diff --git a/tools/test_release_publish.py b/tools/test_release_publish.py index e9ee6fa9..1f24fa9f 100644 --- a/tools/test_release_publish.py +++ b/tools/test_release_publish.py @@ -1,6 +1,8 @@ # This file is part of the Wave language project. # SPDX-License-Identifier: MPL-2.0 """Execute the actual publish step with a fake gh; never contact GitHub.""" +import hashlib +import sys import os from pathlib import Path import shutil @@ -10,7 +12,7 @@ import unittest -@unittest.skipUnless(shutil.which("bash"), "requires bash") +@unittest.skipUnless(os.name != "nt" and shutil.which("bash"), "requires POSIX bash") class ReleasePublishTests(unittest.TestCase): def run_publish(self, remote, status=0): workflow = Path(__file__).resolve().parents[1] / ".github/workflows/release.yml" @@ -46,5 +48,77 @@ def test_changed_missing_malformed_or_unavailable_master_never_publishes(self): self.assertEqual(len(calls), 1) +class ReleaseAssetTests(unittest.TestCase): + def setUp(self): + from tools import check_release_assets + self.validator = check_release_assets + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.version = "0.2.1-pre-beta" + self.names = [] + for target in check_release_assets.ARCHIVE_TARGETS: + name = f"wave-v{self.version}-{target}" + (".zip" if "windows" in target else ".tar.gz") + self.names.append(name) + (self.root / name).write_bytes(name.encode()) + digest = hashlib.sha256(name.encode()).hexdigest() + (self.root / (name + ".sha256")).write_text(f"{digest} {name}\n") + + def verify(self): + return self.validator.verify(self.root, self.version) + + def test_complete_set_writes_exactly_one_verified_record_per_archive(self): + # Accept the binary marker emitted by checksum utilities as well. + sidecar = self.root / (self.names[0] + ".sha256") + sidecar.write_text(sidecar.read_text().replace(" ", " *")) + self.assertEqual(self.verify(), 8) + lines = (self.root / "SHA256SUMS").read_text().splitlines() + self.assertEqual([line[66:] for line in lines], sorted(self.names)) + for line in lines: + self.assertEqual(line[:64], hashlib.sha256((self.root / line[66:]).read_bytes()).hexdigest()) + + def test_empty_duplicate_malformed_wrong_name_and_unsafe_records_fail(self): + name = self.names[0] + sidecar = self.root / (name + ".sha256") + valid = sidecar.read_text() + for invalid in ["", "\n", valid + valid, valid + "\n", "not a checksum\n", + valid.replace(name, self.names[1]), valid.replace(name, "../" + name), + valid.replace(name, str((self.root / name).resolve())), "0" * 64 + f" {name}\n"]: + with self.subTest(record=invalid): + (self.root / "SHA256SUMS").write_text("previous manifest") + sidecar.write_text(invalid) + with self.assertRaises(ValueError): + self.verify() + self.assertEqual((self.root / "SHA256SUMS").read_text(), "previous manifest") + sidecar.write_text(valid) + (self.root / name).write_bytes(b"changed archive") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + self.verify() + + def test_missing_or_unexpected_archives_and_sidecars_fail(self): + for name in (self.names[0], self.names[0] + ".sha256"): + path = self.root / name + contents = path.read_bytes() + path.unlink() + with self.assertRaisesRegex(ValueError, "missing="): + self.verify() + path.write_bytes(contents) + for name in ("unexpected.zip", "unexpected.sha256"): + path = self.root / name + path.touch() + with self.assertRaisesRegex(ValueError, "unexpected="): + self.verify() + path.unlink() + + def test_cli_failure_does_not_create_a_partial_manifest(self): + (self.root / (self.names[0] + ".sha256")).write_text("") + result = subprocess.run([sys.executable, "-m", "tools.check_release_assets", "--directory", str(self.root), + "--version", self.version], cwd=Path(__file__).resolve().parents[1], + text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertIn("exactly one checksum record", result.stderr) + self.assertFalse((self.root / "SHA256SUMS").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/tools/test_runtime_cases.py b/tools/test_runtime_cases.py index f715ad17..e13321bf 100644 --- a/tools/test_runtime_cases.py +++ b/tools/test_runtime_cases.py @@ -17,8 +17,9 @@ class RuntimeReportTests(unittest.TestCase): - def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources=None, alias_root=False): + def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources=None, alias_root=False, metadata="", plan_text=None): calls = [] + self.process_inputs = [] with tempfile.TemporaryDirectory(prefix="runtime root ") as directory: root = Path(directory) if alias_root: @@ -33,7 +34,7 @@ def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources= for name in selected: path = root / "tests/cases" / name path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("fun main() {}") + path.write_text((f"// wave-test: {metadata}\n" if metadata else "") + "fun main() {}") options = argparse.Namespace( wavec=Path(sys.executable), target_id="test-target", sources=selected if sources is None else sources, report_json=root / "report.json", qemu="fake-qemu", sysroot=root, @@ -47,11 +48,17 @@ def process(command, **kwargs): self.assertTrue(Path(command[2]).is_relative_to(root.resolve())) index = len(calls) calls.append(command) + self.process_inputs.append(kwargs.get("input")) outcome = outcomes[index] if index < len(outcomes) else 0 if isinstance(outcome, BaseException): raise outcome - if "-o" in command and outcome == 0 and not missing_output: + if "-o" in command and "--dry-run" not in command and outcome == 0 and not missing_output: Path(command[command.index("-o") + 1]).touch() + if "--dry-run" in command and outcome == 0: + output = command[command.index("-o") + 1] + payload = json.dumps({"link": {"output": output}, "execute": { + "program": "fake-node", "args": [output]}}) if plan_text is None else plan_text + return subprocess.CompletedProcess(command, 0, payload, "") # A real short-lived subprocess supplies stdout, stderr, and exit status. return run_process([sys.executable, "-c", "import sys; print('runtime output'); " @@ -73,21 +80,25 @@ def test_wasm_success_is_explicitly_runtime_and_preserves_selection(self): "mode": "target", "id": "test-target", "target": "test-triple", "executor": "wasm", "suites": ["shared"]}) self.assertEqual(report["summary"]["pass"], 2) - self.assertTrue(all(command[1] == "run" for command in calls)) - self.assertEqual(report["tests"][0]["commands"][0]["phase"], "build-and-run") + self.assertEqual([c["phase"] for c in report["tests"][0]["commands"]], ["build", "plan", "run"]) + self.assertEqual(calls[0][1], "build") + self.assertIn("--dry-run", calls[1]) + self.assertEqual(calls[1][1], "build") + self.assertIn("--run", calls[1]) + self.assertEqual(calls[2][0], "fake-node") def test_compiler_commands_use_same_checkout_through_directory_alias(self): for executor in ("wasm", "qemu"): with self.subTest(executor=executor): status, _, calls = self.run_suite(executor=executor, alias_root=True) self.assertEqual(status, 0) - self.assertEqual(sum("--std-root" in command for command in calls), 2) + self.assertEqual(sum("--std-root" in command for command in calls), 4 if executor == "wasm" else 2) def test_host_import_failure_is_reported_and_later_case_still_runs(self): - status, report, _ = self.run_suite(outcomes=[7]) + status, report, _ = self.run_suite(outcomes=[0, 0, 7]) self.assertEqual(status, 1) self.assertEqual([row["status"] for row in report["tests"]], ["fail", "pass"]) - command = report["tests"][0]["commands"][0] + command = report["tests"][0]["commands"][-1] self.assertEqual(command["actual_exit"], 7) self.assertEqual(command["expected_exit"], 0) self.assertIn("env.printf", command["stderr"]) @@ -103,6 +114,7 @@ def test_qemu_build_and_execution_have_separate_status_and_use_exact_output(self self.assertEqual(Path(build[build.index("--std-root") + 1]).name, "std") self.assertIn("runtime root ", build[build.index("--std-root") + 1]) self.assertEqual(run[0], "fake-qemu") + self.assertEqual(Path(build[build.index("--sysroot") + 1]), Path(run[2]).resolve()) self.assertTrue(any(arg.endswith("main.wave") for arg in calls[2])) def test_qemu_compile_failure_or_missing_artifact_never_counts_as_execution(self): @@ -129,13 +141,13 @@ def test_timeout_and_launch_failure_preserve_reason_without_passing(self): for error, expected in [(subprocess.TimeoutExpired("host", 6, output=b"partial"), "timeout"), (OSError("host unavailable"), "fail")]: with self.subTest(error=error): - status, report, _ = self.run_suite(outcomes=[error]) + status, report, _ = self.run_suite(outcomes=[0, 0, error]) self.assertEqual(status, 1) self.assertEqual(report["tests"][0]["status"], expected) self.assertEqual(report["tests"][1]["status"], "pass") self.assertIn("reason", report["tests"][0]) if expected == "timeout": - self.assertEqual(report["tests"][0]["commands"][0]["output"], "partial") + self.assertEqual(report["tests"][0]["commands"][-1]["output"], "partial") def test_interruption_keeps_pending_cases_not_run(self): status, report, calls = self.run_suite(outcomes=[KeyboardInterrupt()]) @@ -152,6 +164,51 @@ def test_empty_unsafe_or_duplicate_selection_fails_before_execution(self): self.assertIn("error", report) self.assertFalse(calls) + def test_metadata_input_and_nonzero_exit_apply_only_to_execution(self): + for executor, outcomes in (("qemu", [0, 7, 0, 7]), ("wasm", [0, 0, 7, 0, 0, 7])): + with self.subTest(executor=executor): + status, report, _ = self.run_suite(executor=executor, outcomes=outcomes, + metadata="stdin=3, expected-exit=7") + self.assertEqual(status, 0) + records = [c for row in report["tests"] for c in row["commands"]] + for record, stdin in zip(records, self.process_inputs): + self.assertEqual(record["expected_exit"], 7 if record["phase"] == "run" else 0) + self.assertEqual(stdin, "3\n" if record["phase"] == "run" else None) + + def test_build_failure_matching_expected_program_exit_still_fails(self): + for executor in ("qemu", "wasm"): + with self.subTest(executor=executor): + status, report, _ = self.run_suite(executor=executor, outcomes=[7], metadata="expected-exit=7") + self.assertEqual(status, 1) + row = report["tests"][0] + self.assertEqual(row["status"], "fail") + self.assertEqual([c["phase"] for c in row["commands"]], ["build"]) + self.assertEqual(row["commands"][0]["expected_exit"], 0) + + def test_invalid_or_unsupported_metadata_fails_before_any_command(self): + for metadata in ("mode=build, runner=compile", "runner=server", "udp-input=true", "unknown=value"): + with self.subTest(metadata=metadata): + status, report, calls = self.run_suite(metadata=metadata) + self.assertEqual(status, 2) + self.assertIn("error", report) + self.assertFalse(calls) + + def test_wasm_missing_output_or_failed_plan_never_counts_as_execution(self): + for options in ({"missing_output": True}, {"outcomes": [0, 7]}, {"plan_text": "not json"}, + {"plan_text": "{}"}): + with self.subTest(options=options): + status, report, _ = self.run_suite(**options) + self.assertEqual(status, 1) + self.assertNotIn("run", [c["phase"] for c in report["tests"][0]["commands"]]) + + def test_wasm_plan_rejects_different_output_and_invalid_command_shapes(self): + output = Path("/temporary/case.wasm") + for plan in ({"link": {"output": "other"}}, + {"link": {"output": str(output)}, "execute": {"program": "node", "args": [17]}}, + {"link": {"output": str(output)}, "execute": {"program": "node", "args": ["other"]}}): + with self.subTest(plan=plan), self.assertRaises(ValueError): + runner.wasm_execute_plan(json.dumps(plan), output) + def test_timeout_rejects_unbounded_values(self): for value in ("nan", "inf", "0", "-1"): with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): diff --git a/tools/test_x.py b/tools/test_x.py index 3fa90a79..404681b8 100644 --- a/tools/test_x.py +++ b/tools/test_x.py @@ -1,5 +1,8 @@ """Tests for the repository build driver.""" +import os +import tempfile +from unittest.mock import patch import subprocess import sys import unittest @@ -24,5 +27,62 @@ def test_unknown_command_fails_without_running_a_build(self): self.assertIn("Usage: x.py", result.stdout) +class CleanupTests(unittest.TestCase): + def test_clean_uses_repository_paths_and_preserves_unrelated_files(self): + import x + with tempfile.TemporaryDirectory(prefix="cleanup with spaces ") as directory: + root = Path(directory) / "repository" + other = Path(directory) / "unrelated" + root.mkdir() + other.mkdir() + for folder in (root / "target", root / "dist", other / "target", root / ".tmp"): + folder.mkdir() + (folder / "sentinel").write_text("preserve unless owned") + owned = ["wave-v0.2.1-pre-beta-x86_64-linux-gnu.tar.gz", + "wave-v0.2.1-pre-beta-aarch64-pc-windows-msvc.zip", + "wave-v0.2.1-pre-beta-x86_64-linux-gnu.tar.gz.sha256"] + unowned = ["backup.zip", "source.tar.gz", "wave-custom.zip", + "wave-v0.2.1-pre-beta-unknown-target.tar.gz"] + for name in owned + unowned: + (root / name).write_text("repository") + (other / name).write_text("unrelated") + cwd = Path.cwd() + try: + os.chdir(other) + with patch.object(x, "ROOT", root), patch.object(x, "DIST_DIR", root / "dist"): + x.cmd_clean() + x.cmd_clean() # Missing build outputs are harmless. + finally: + os.chdir(cwd) + self.assertFalse((root / "target").exists()) + self.assertFalse((root / "dist").exists()) + self.assertTrue((other / "target/sentinel").exists()) + self.assertTrue((root / ".tmp/sentinel").exists()) + for name in owned: + self.assertFalse((root / name).exists()) + for name in unowned: + self.assertEqual((root / name).read_text(), "repository") + for name in owned + unowned: + self.assertEqual((other / name).read_text(), "unrelated") + + def test_clean_unlinks_build_directory_alias_without_deleting_its_target(self): + import x + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "repo" + outside = Path(directory) / "outside" + root.mkdir() + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_text("keep") + try: + (root / "target").symlink_to(outside, target_is_directory=True) + except OSError as error: + self.skipTest(f"directory symlinks unavailable: {error}") + with patch.object(x, "ROOT", root), patch.object(x, "DIST_DIR", root / "dist"): + x.cmd_clean() + self.assertFalse((root / "target").is_symlink()) + self.assertEqual(sentinel.read_text(), "keep") + + if __name__ == "__main__": unittest.main() diff --git a/x.py b/x.py index d08cb8a7..8d4a773a 100644 --- a/x.py +++ b/x.py @@ -20,6 +20,7 @@ import shutil import platform import json +import re from tools import windows_package try: @@ -1290,12 +1291,21 @@ def cmd_release(): # ------------------------------------------------------ def cmd_clean(): print("[*] Cleaning build artifacts...") - shutil.rmtree("target", ignore_errors=True) - shutil.rmtree(DIST_DIR, ignore_errors=True) - - for f in os.listdir(ROOT): - if f.endswith(".tar.gz") or f.endswith(".zip"): - os.remove(f) + for directory in (ROOT / "target", DIST_DIR): + if directory.is_symlink(): + directory.unlink() + elif directory.exists(): + shutil.rmtree(directory) + + # Only x.py package names belong to this cleanup, never arbitrary archives. + targets = "|".join(re.escape(release_target_name(t)) for t in ALL_TARGETS) + generated = re.compile( + rf"{re.escape(NAME)}-v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?" + rf"-(?:{targets})\.(?:tar\.gz|zip)(?:\.sha256)?" + ) + for path in ROOT.iterdir(): + if generated.fullmatch(path.name) and (path.is_file() or path.is_symlink()): + path.unlink() print("[+] Cleaned.\n")