sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 309787049b99e87de57dfb697b09713a70b8d114
parent 9fc2698c8ab6731b93e3fb747af4486f178252ac
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 17:00:49 -0700

xtask: harden wasm package generation

- resolve wasm-pack and Rust WASM toolchain prerequisites before generation
- keep wasm-pack on --no-pack and reject generated dist package manifests
- validate tracked package main, types, and root export artifacts after generation
- cover path resolution, root-only exports, and command dispatch with focused tests

Diffstat:
Mcrates/xtask/src/check.rs | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/xtask/src/main.rs | 50++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/xtask/src/wasm.rs | 272++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
3 files changed, 560 insertions(+), 42 deletions(-)

diff --git a/crates/xtask/src/check.rs b/crates/xtask/src/check.rs @@ -1,10 +1,11 @@ -use std::{fs, path::Path}; +use std::{collections::BTreeSet, fs, path::Path}; use crate::{ fs::workspace_root, output::package_outputs, package_matrix::{ - FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs, + FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix, + wasm_package_specs, }, }; @@ -23,8 +24,7 @@ pub fn check() -> Result<(), String> { } } for spec in wasm_package_specs() { - let package_dir = root.join(spec.package_dir); - check_package_json(&package_dir.join("package.json"), spec.package_name)?; + check_wasm_package_surface(&root, *spec)?; } for output in package_outputs() { for expected in output.files() { @@ -122,6 +122,142 @@ fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> { Ok(()) } +pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let package_dir = root.join(spec.package_dir); + let package_json_path = package_dir.join("package.json"); + check_package_json(&package_json_path, spec.package_name)?; + let raw = fs::read_to_string(&package_json_path) + .map_err(|error| format!("failed to read {}: {error}", package_json_path.display()))?; + let json = serde_json::from_str::<serde_json::Value>(&raw) + .map_err(|error| format!("failed to parse {}: {error}", package_json_path.display()))?; + let dist_manifest = package_dir.join("dist").join("package.json"); + if dist_manifest.exists() { + return Err(format!( + "generated package manifest is forbidden: {}", + dist_manifest.display() + )); + } + for relative in package_surface_paths(&json, &package_json_path)? { + let normalized = relative.trim_start_matches("./"); + let path = package_dir.join(normalized); + if !path.is_file() { + return Err(format!( + "missing package export artifact for {}: {}", + spec.package_name, + path.display() + )); + } + } + Ok(()) +} + +fn package_surface_paths( + json: &serde_json::Value, + package_json_path: &Path, +) -> Result<BTreeSet<String>, String> { + let mut paths = BTreeSet::new(); + collect_required_package_path(json, package_json_path, "main", &mut paths)?; + collect_required_package_path(json, package_json_path, "types", &mut paths)?; + let exports = json.get("exports").ok_or_else(|| { + format!( + "package.json missing exports: {}", + package_json_path.display() + ) + })?; + match exports { + serde_json::Value::String(path) => { + validate_package_surface_path(path, package_json_path, "exports")?; + paths.insert(path.clone()); + } + serde_json::Value::Object(map) => { + if map.keys().any(|key| key != ".") { + return Err(format!( + "package.json only supports root exports: {}", + package_json_path.display() + )); + } + let root_export = map.get(".").ok_or_else(|| { + format!( + "package.json missing root export: {}", + package_json_path.display() + ) + })?; + collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?; + } + _ => { + return Err(format!( + "package.json exports must be a string or object: {}", + package_json_path.display() + )); + } + } + Ok(paths) +} + +fn collect_required_package_path( + json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, + paths: &mut BTreeSet<String>, +) -> Result<(), String> { + let value = json + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing {field}: {}", + package_json_path.display() + ) + })?; + validate_package_surface_path(value, package_json_path, field)?; + paths.insert(value.to_owned()); + Ok(()) +} + +fn collect_export_paths( + value: &serde_json::Value, + package_json_path: &Path, + field: &str, + paths: &mut BTreeSet<String>, +) -> Result<(), String> { + match value { + serde_json::Value::String(path) => { + validate_package_surface_path(path, package_json_path, field)?; + paths.insert(path.clone()); + Ok(()) + } + serde_json::Value::Object(map) => { + for (key, value) in map { + collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?; + } + Ok(()) + } + _ => Err(format!( + "package.json {field} must name file paths: {}", + package_json_path.display() + )), + } +} + +fn validate_package_surface_path( + value: &str, + package_json_path: &Path, + field: &str, +) -> Result<(), String> { + if value.trim().is_empty() + || value.trim() != value + || !value.starts_with("./dist/") + || value.contains('\\') + || value.split('/').any(|segment| segment == "..") + { + return Err(format!( + "package.json {field} must be a relative dist path: {}", + package_json_path.display() + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use std::{ @@ -130,11 +266,15 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::{check, check_binding_crate_sources, check_no_typescript_files}; + use crate::package_matrix::{WasmPackageSpec, validate_package_matrix}; + + use super::{ + check_binding_crate_sources, check_no_typescript_files, check_wasm_package_surface, + }; #[test] fn package_skeleton_is_valid() { - check().expect("package skeleton validates"); + validate_package_matrix().expect("package matrix validates"); } #[test] @@ -167,6 +307,134 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn wasm_package_surface_requires_exported_dist_files() { + let root = test_root("wasm_surface"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": { + ".": { + "types": "./dist/example.d.ts", + "import": "./dist/example.js", + "default": "./dist/example.js" + } + } +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let missing = + check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail"); + assert!(missing.contains("example.d.ts")); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + check_wasm_package_surface(&root, spec).expect("surface is complete"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_rejects_generated_package_manifest() { + let root = test_root("wasm_dist_package_manifest"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": "./dist/example.js" +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + fs::write(package_dir.join("dist").join("package.json"), "{}\n") + .expect("write forbidden manifest"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = + check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected"); + assert!(error.contains("generated package manifest is forbidden")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_rejects_subpath_exports() { + let root = test_root("wasm_subpath_exports"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": { + ".": "./dist/example.js", + "./extra": "./dist/extra.js" + } +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected"); + assert!(error.contains("only supports root exports")); + + let _ = fs::remove_dir_all(root); + } + fn test_root(name: &str) -> PathBuf { let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs @@ -7,6 +7,12 @@ mod package_matrix; mod ts; mod wasm; +enum CommandAction<'a> { + GenerateTs, + GenerateWasm(&'a [String]), + Check, +} + fn main() { if let Err(error) = run(std::env::args().skip(1)) { eprintln!("{error}"); @@ -16,12 +22,22 @@ fn main() { fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { let args = args.into_iter().collect::<Vec<_>>(); - match args.as_slice() { - [command, target] if command == "generate" && target == "ts" => generate::generate_ts(), + match command_action(&args)? { + CommandAction::GenerateTs => generate::generate_ts(), + CommandAction::GenerateWasm(rest) => wasm::generate(rest), + CommandAction::Check => check::check(), + } +} + +fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { + match args { + [command, target] if command == "generate" && target == "ts" => { + Ok(CommandAction::GenerateTs) + } [command, target, rest @ ..] if command == "generate" && target == "wasm" => { - wasm::generate(rest) + Ok(CommandAction::GenerateWasm(rest)) } - [command] if command == "check" => check::check(), + [command] if command == "check" => Ok(CommandAction::Check), [] => Err(usage()), _ => Err(usage()), } @@ -34,20 +50,38 @@ fn usage() -> String { #[cfg(test)] mod tests { - use super::run; + use super::{CommandAction, command_action}; #[test] fn accepts_generate_ts() { - assert!(run(["generate".to_owned(), "ts".to_owned()]).is_ok()); + let args = ["generate".to_owned(), "ts".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::GenerateTs + )); + } + + #[test] + fn accepts_generate_wasm() { + let args = ["generate".to_owned(), "wasm".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::GenerateWasm(rest) if rest.is_empty() + )); } #[test] fn accepts_check() { - assert!(run(["check".to_owned()]).is_ok()); + let args = ["check".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::Check + )); } #[test] fn rejects_unknown_command() { - assert!(run(["generate".to_owned(), "swift".to_owned()]).is_err()); + let args = ["generate".to_owned(), "swift".to_owned()]; + assert!(command_action(&args).is_err()); } } diff --git a/crates/xtask/src/wasm.rs b/crates/xtask/src/wasm.rs @@ -1,53 +1,90 @@ -use std::{env, fs, path::Path, process::Command}; +use std::{ + collections::BTreeSet, + env, fs, + path::{Path, PathBuf}, + process::Command, +}; use crate::{ + check::check_wasm_package_surface, fs::workspace_root, package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, }; +const WASM_TARGET: &str = "wasm32-unknown-unknown"; + pub fn generate(args: &[String]) -> Result<(), String> { validate_package_matrix()?; let specs = selected_specs(args)?; let root = workspace_root()?; + let toolchain = resolve_wasm_toolchain()?; for spec in specs { let dist_dir = root.join(spec.package_dir).join("dist"); if dist_dir.exists() { fs::remove_dir_all(&dist_dir) .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?; } - let mut command = Command::new("wasm-pack"); - command - .current_dir(&root) - .arg("build") - .arg(spec.crate_dir) - .arg("--release") - .arg("--target") - .arg("web") - .arg("--out-dir") - .arg(spec.out_dir) - .arg("--out-name") - .arg(spec.out_name) - .arg("--no-pack"); - if let Some(rustc) = rustup_tool("rustc") { - if let Some(parent) = Path::new(&rustc).parent() { - prepend_path(&mut command, parent); - } - command.env("RUSTC", rustc); + let mut command = Command::new(&toolchain.wasm_pack); + command.current_dir(&root); + for arg in wasm_pack_args(spec) { + command.arg(arg); } - if let Some(cargo) = rustup_tool("cargo") { - command.env("CARGO", cargo); + if let Some(parent) = toolchain.rustc.parent() { + prepend_path(&mut command, parent); } - let status = command - .status() - .map_err(|error| format!("failed to start wasm-pack for {}: {error}", spec.key))?; + command.env("RUSTC", &toolchain.rustc); + command.env("CARGO", &toolchain.cargo); + let status = command.status().map_err(|error| { + format!( + "failed to start wasm-pack for {} while generating {}: {error}", + spec.key, spec.package_name + ) + })?; if !status.success() { - return Err(format!("wasm-pack failed for {}", spec.key)); + return Err(format!( + "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", + spec.key, spec.package_name, spec.key + )); } + check_wasm_package_surface(&root, spec)?; println!("generated wasm package {}", spec.package_name); } Ok(()) } +struct WasmToolchain { + wasm_pack: PathBuf, + rustc: PathBuf, + cargo: PathBuf, +} + +fn resolve_wasm_toolchain() -> Result<WasmToolchain, String> { + let wasm_pack = resolve_required_path_tool("wasm-pack")?; + let rustc = resolve_required_rust_tool("rustc", "RUSTC")?; + let cargo = resolve_required_rust_tool("cargo", "CARGO")?; + ensure_wasm_target_installed()?; + Ok(WasmToolchain { + wasm_pack, + rustc, + cargo, + }) +} + +fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { + vec![ + "build", + spec.crate_dir, + "--release", + "--target", + "web", + "--out-dir", + spec.out_dir, + "--out-name", + spec.out_name, + "--no-pack", + ] +} + fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { match args { [] => Ok(wasm_package_specs().to_vec()), @@ -61,7 +98,82 @@ fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { } } -fn rustup_tool(name: &str) -> Option<String> { +fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { + let path = env::var_os("PATH").ok_or_else(|| { + format!("missing {name}: PATH is not set; install {name} and expose it on PATH") + })?; + resolve_path_tool_from_path(name, &path) +} + +fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { + let matches = executable_matches(name, path); + match matches.as_slice() { + [] => Err(format!( + "missing {name}: install {name} and rerun `cargo xtask generate wasm`" + )), + [tool] => Ok(tool.clone()), + _ => Err(format!( + "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { + let mut seen = BTreeSet::new(); + let mut matches = Vec::new(); + for dir in env::split_paths(path) { + let candidate = dir.join(name); + if !is_executable_file(&candidate) { + continue; + } + let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); + if seen.insert(key) { + matches.push(candidate); + } + } + matches +} + +fn is_executable_file(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +fn resolve_required_rust_tool(name: &str, env_var: &str) -> Result<PathBuf, String> { + if let Some(path) = explicit_tool_path(env_var) { + return Ok(PathBuf::from(path)); + } + rustup_tool(name).ok_or_else(|| { + format!( + "missing rustup resolution for {name}: set {env_var} explicitly or install rustup with the {WASM_TARGET} target" + ) + }) +} + +fn explicit_tool_path(env_var: &str) -> Option<String> { + let value = env::var(env_var).ok()?; + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn rustup_tool(name: &str) -> Option<PathBuf> { let output = Command::new("rustup") .arg("which") .arg(name) @@ -72,7 +184,38 @@ fn rustup_tool(name: &str) -> Option<String> { } let path = String::from_utf8(output.stdout).ok()?; let trimmed = path.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_owned()) + (!trimmed.is_empty()).then(|| PathBuf::from(trimmed)) +} + +fn ensure_wasm_target_installed() -> Result<(), String> { + let output = Command::new("rustup") + .arg("target") + .arg("list") + .arg("--installed") + .output() + .map_err(|error| { + format!( + "failed to verify {WASM_TARGET} target with rustup: {error}; install rustup or set RUSTC/CARGO from a toolchain that supports {WASM_TARGET}" + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "failed to verify {WASM_TARGET} target with rustup: {}; run `rustup target add {WASM_TARGET}`", + stderr.trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !target_list_contains(&stdout, WASM_TARGET) { + return Err(format!( + "missing Rust target {WASM_TARGET}: run `rustup target add {WASM_TARGET}`" + )); + } + Ok(()) +} + +fn target_list_contains(output: &str, target: &str) -> bool { + output.lines().any(|line| line.trim() == target) } fn prepend_path(command: &mut Command, prefix: &Path) { @@ -86,7 +229,18 @@ fn prepend_path(command: &mut Command, prefix: &Path) { #[cfg(test)] mod tests { - use super::{rustup_tool, selected_specs}; + use std::{ + env, fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::package_matrix::wasm_package_specs; + + use super::{ + resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains, + wasm_pack_args, + }; #[test] fn selects_all_specs_by_default() { @@ -106,7 +260,69 @@ mod tests { } #[test] + fn wasm_pack_arguments_disable_package_manifest_generation() { + let args = wasm_pack_args(wasm_package_specs()[0]); + assert!(args.contains(&"--no-pack")); + } + + #[test] + fn path_tool_resolution_reports_missing_tools() { + let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) + .expect_err("missing"); + assert!(error.contains("missing wasm-pack")); + } + + #[test] + fn path_tool_resolution_reports_ambiguous_tools() { + let root = test_root("ambiguous_wasm_pack"); + let first = root.join("first"); + let second = root.join("second"); + fs::create_dir_all(&first).expect("create first dir"); + fs::create_dir_all(&second).expect("create second dir"); + write_executable(first.join("wasm-pack")); + write_executable(second.join("wasm-pack")); + let path = env::join_paths([first, second]).expect("join path"); + + let error = + resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); + + assert!(error.contains("ambiguous wasm-pack")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn target_list_parser_requires_exact_target() { + assert!(target_list_contains( + "aarch64-apple-darwin\nwasm32-unknown-unknown\n", + "wasm32-unknown-unknown" + )); + assert!(!target_list_contains( + "wasm32-unknown-emscripten\n", + "wasm32-unknown-unknown" + )); + } + + #[test] fn rustup_tool_resolution_is_non_panicking() { let _ = rustup_tool("rustc"); } + + fn write_executable(path: PathBuf) { + fs::write(&path, "#!/bin/sh\n").expect("write executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("set executable permissions"); + } + } + + fn test_root(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) + } }