wasm.rs (42000B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 env, 4 ffi::OsStr, 5 fs, io, 6 path::{Path, PathBuf}, 7 process::{Command, Stdio}, 8 }; 9 10 use serde::{Deserialize, Serialize}; 11 use sha2::{Digest, Sha256}; 12 13 use crate::{ 14 build_control::Mode, 15 sdk_generation::{ 16 package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, 17 wasm_declarations::write_declaration_files, 18 }, 19 }; 20 21 pub(crate) const WASM_TARGET: &str = "wasm32-unknown-unknown"; 22 const WASM_C_COMPILER_ENV: &str = "CC_wasm32_unknown_unknown"; 23 const WASM_CFLAGS_ENV: &str = "CFLAGS_wasm32_unknown_unknown"; 24 const WASM_C_TARGET_ARG: &str = "--target=wasm32-unknown-unknown"; 25 26 pub fn generate( 27 source_root: &Path, 28 consumer_root: &Path, 29 args: &[String], 30 mode: Mode, 31 ) -> Result<(), String> { 32 validate_package_matrix()?; 33 let specs = selected_specs(args)?; 34 let toolchain = resolve_wasm_toolchain(source_root)?; 35 println!( 36 "using Rust toolchain {} for {}: rustc={}, cargo={}", 37 toolchain.channel(), 38 WASM_TARGET, 39 toolchain.rustc().display(), 40 toolchain.cargo().display() 41 ); 42 let wasm_c_compiler = if specs 43 .iter() 44 .any(|spec| wasm_package_requires_c_compiler(*spec)) 45 { 46 let compiler = resolve_wasm_c_compiler()?; 47 println!( 48 "using WASM C compiler for {}: {}", 49 WASM_TARGET, 50 compiler.path.display() 51 ); 52 Some(compiler) 53 } else { 54 None 55 }; 56 for spec in specs { 57 let staging = tempfile::tempdir() 58 .map_err(|error| format!("failed to create WASM staging directory: {error}"))?; 59 let dist_dir = staging.path().join(spec.package_dir).join("dist"); 60 fs::create_dir_all(&dist_dir) 61 .map_err(|error| format!("failed to create {}: {error}", dist_dir.display()))?; 62 let mut command = Command::new(&toolchain.wasm_pack); 63 command.current_dir(source_root); 64 for arg in wasm_pack_args(spec) { 65 command.arg(arg); 66 } 67 command.arg("--out-dir").arg(&dist_dir); 68 toolchain.apply_to_command(&mut command); 69 if let Some(compiler) = wasm_c_compiler.as_ref() 70 && wasm_package_requires_c_compiler(spec) 71 { 72 compiler.apply_to_command(&mut command); 73 } 74 let status = command.status().map_err(|error| { 75 format!( 76 "failed to start wasm-pack for {} while generating {}: {error}", 77 spec.key, spec.package_name 78 ) 79 })?; 80 if !status.success() { 81 return Err(format!( 82 "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", 83 spec.key, spec.package_name, spec.key 84 )); 85 } 86 remove_wasm_pack_gitignore(&dist_dir, spec)?; 87 write_declaration_files(staging.path(), spec)?; 88 write_provenance_manifest(source_root, consumer_root, staging.path(), spec)?; 89 validate_staged_outputs(&dist_dir, spec)?; 90 install_outputs(source_root, consumer_root, staging.path(), spec, mode)?; 91 println!("generated wasm package {}", spec.package_name); 92 } 93 Ok(()) 94 } 95 96 #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] 97 struct WasmProvenanceManifest { 98 schema_version: u16, 99 package: String, 100 package_version: String, 101 rust_crate: String, 102 rust_crate_version: String, 103 target: String, 104 deterministic_codec: bool, 105 networking: bool, 106 signing: bool, 107 source_sha256: String, 108 outputs: BTreeMap<String, String>, 109 } 110 111 fn write_provenance_manifest( 112 source_root: &Path, 113 consumer_root: &Path, 114 staged_root: &Path, 115 spec: WasmPackageSpec, 116 ) -> Result<(), String> { 117 let manifest = provenance_manifest(source_root, consumer_root, staged_root, spec)?; 118 let dist_dir = staged_root.join(spec.package_dir).join("dist"); 119 let path = provenance_manifest_path(&dist_dir, spec); 120 let mut rendered = serde_json::to_string_pretty(&manifest) 121 .map_err(|error| format!("failed to render {}: {error}", path.display()))?; 122 rendered.push('\n'); 123 fs::write(&path, rendered) 124 .map_err(|error| format!("failed to write {}: {error}", path.display())) 125 } 126 127 fn provenance_manifest( 128 source_root: &Path, 129 consumer_root: &Path, 130 output_root: &Path, 131 spec: WasmPackageSpec, 132 ) -> Result<WasmProvenanceManifest, String> { 133 let package_version = 134 manifest_version(&consumer_root.join(spec.package_dir).join("package.json"))?; 135 let rust_crate_version = 136 manifest_version(&source_root.join(spec.crate_dir).join("Cargo.toml"))?; 137 let source_sha256 = source_tree_sha256(source_root, spec)?; 138 let dist_dir = output_root.join(spec.package_dir).join("dist"); 139 let mut outputs = BTreeMap::new(); 140 for relative in wasm_output_files(spec) { 141 let path = dist_dir.join(&relative); 142 let bytes = fs::read(&path) 143 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 144 outputs.insert(relative, format!("{:x}", Sha256::digest(bytes))); 145 } 146 Ok(WasmProvenanceManifest { 147 schema_version: 1, 148 package: spec.package_name.to_owned(), 149 package_version, 150 rust_crate: spec.crate_name.to_owned(), 151 rust_crate_version, 152 target: WASM_TARGET.to_owned(), 153 deterministic_codec: spec.key == "event_codec", 154 networking: false, 155 signing: false, 156 source_sha256, 157 outputs, 158 }) 159 } 160 161 fn validate_provenance_manifest( 162 source_root: &Path, 163 consumer_root: &Path, 164 spec: WasmPackageSpec, 165 ) -> Result<(), String> { 166 let path = provenance_manifest_path(&consumer_root.join(spec.package_dir).join("dist"), spec); 167 let raw = fs::read_to_string(&path) 168 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 169 let actual: WasmProvenanceManifest = serde_json::from_str(&raw) 170 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; 171 let expected = provenance_manifest(source_root, consumer_root, consumer_root, spec)?; 172 if actual != expected { 173 return Err(format!( 174 "stale or invalid WASM provenance manifest: {}", 175 path.display() 176 )); 177 } 178 Ok(()) 179 } 180 181 fn install_outputs( 182 source_root: &Path, 183 consumer_root: &Path, 184 staged_root: &Path, 185 spec: WasmPackageSpec, 186 mode: Mode, 187 ) -> Result<(), String> { 188 let staged_dist = staged_root.join(spec.package_dir).join("dist"); 189 let destination_dist = consumer_root.join(spec.package_dir).join("dist"); 190 for relative in wasm_output_files(spec) 191 .into_iter() 192 .chain([format!("{}.provenance.json", spec.out_name)]) 193 { 194 let staged = staged_dist.join(&relative); 195 let bytes = fs::read(&staged) 196 .map_err(|error| format!("failed to read {}: {error}", staged.display()))?; 197 let destination = destination_dist.join(relative); 198 match mode { 199 Mode::Write => crate::build_control::atomic_write_bytes(&destination, &bytes)?, 200 Mode::Check => { 201 let current = fs::read(&destination).map_err(|error| { 202 format!("failed to read {}: {error}", destination.display()) 203 })?; 204 if current != bytes { 205 return Err(format!( 206 "stale generated SDK WASM output: {}", 207 destination.display() 208 )); 209 } 210 } 211 } 212 } 213 validate_provenance_manifest(source_root, consumer_root, spec) 214 } 215 216 fn validate_staged_outputs(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { 217 let expected = wasm_output_files(spec) 218 .into_iter() 219 .chain([format!("{}.provenance.json", spec.out_name)]) 220 .collect::<BTreeSet<_>>(); 221 let actual = fs::read_dir(dist_dir) 222 .map_err(|error| format!("failed to read {}: {error}", dist_dir.display()))? 223 .map(|entry| { 224 entry 225 .map_err(|error| format!("failed to enumerate {}: {error}", dist_dir.display())) 226 .and_then(|entry| { 227 entry 228 .file_name() 229 .into_string() 230 .map_err(|_| format!("non-UTF-8 WASM output in {}", dist_dir.display())) 231 }) 232 }) 233 .collect::<Result<BTreeSet<_>, _>>()?; 234 if actual != expected { 235 return Err(format!( 236 "unexpected WASM output inventory for {}: expected {expected:?}, found {actual:?}", 237 spec.package_name 238 )); 239 } 240 Ok(()) 241 } 242 243 fn manifest_version(path: &Path) -> Result<String, String> { 244 let raw = fs::read_to_string(path) 245 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 246 if path.extension() == Some(OsStr::new("json")) { 247 let value: serde_json::Value = serde_json::from_str(&raw) 248 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; 249 return value["version"] 250 .as_str() 251 .map(str::to_owned) 252 .ok_or_else(|| format!("{} must define string version", path.display())); 253 } 254 let value: toml::Value = raw 255 .parse() 256 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; 257 value["package"]["version"] 258 .as_str() 259 .map(str::to_owned) 260 .ok_or_else(|| format!("{} must define explicit package.version", path.display())) 261 } 262 263 fn source_tree_sha256(root: &Path, spec: WasmPackageSpec) -> Result<String, String> { 264 let crate_dir = root.join(spec.crate_dir); 265 let mut files = vec![crate_dir.join("Cargo.toml")]; 266 collect_source_files(&crate_dir.join("src"), &mut files)?; 267 files.sort(); 268 let mut hasher = Sha256::new(); 269 for path in files { 270 let relative = path 271 .strip_prefix(root) 272 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; 273 hasher.update(relative.to_string_lossy().as_bytes()); 274 hasher.update([0]); 275 hasher.update( 276 fs::read(&path) 277 .map_err(|error| format!("failed to read {}: {error}", path.display()))?, 278 ); 279 hasher.update([0]); 280 } 281 Ok(format!("{:x}", hasher.finalize())) 282 } 283 284 fn collect_source_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> { 285 let mut entries = fs::read_dir(dir) 286 .map_err(|error| format!("failed to read {}: {error}", dir.display()))? 287 .collect::<Result<Vec<_>, _>>() 288 .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; 289 entries.sort_by_key(std::fs::DirEntry::file_name); 290 for entry in entries { 291 let path = entry.path(); 292 if path.is_dir() { 293 collect_source_files(&path, files)?; 294 } else if path.is_file() { 295 files.push(path); 296 } 297 } 298 Ok(()) 299 } 300 301 fn wasm_output_files(spec: WasmPackageSpec) -> [String; 4] { 302 [ 303 format!("{}.js", spec.out_name), 304 format!("{}.d.ts", spec.out_name), 305 format!("{}_bg.wasm", spec.out_name), 306 format!("{}_bg.wasm.d.ts", spec.out_name), 307 ] 308 } 309 310 fn provenance_manifest_path(dist_dir: &Path, spec: WasmPackageSpec) -> PathBuf { 311 dist_dir.join(format!("{}.provenance.json", spec.out_name)) 312 } 313 314 struct WasmToolchain { 315 wasm_pack: PathBuf, 316 rust: ResolvedRustToolchain, 317 } 318 319 impl WasmToolchain { 320 fn apply_to_command(&self, command: &mut Command) { 321 self.rust.apply_to_command(command); 322 } 323 324 fn rustc(&self) -> &Path { 325 &self.rust.rustc 326 } 327 328 fn cargo(&self) -> &Path { 329 &self.rust.cargo 330 } 331 332 fn channel(&self) -> &str { 333 &self.rust.channel 334 } 335 } 336 337 pub(crate) struct ResolvedRustToolchain { 338 pub(crate) channel: String, 339 pub(crate) rustc: PathBuf, 340 pub(crate) cargo: PathBuf, 341 pub(crate) bin_dir: PathBuf, 342 } 343 344 impl ResolvedRustToolchain { 345 pub(crate) fn apply_to_command(&self, command: &mut Command) { 346 prepend_path(command, &self.bin_dir); 347 command.env("RUSTC", &self.rustc); 348 command.env("CARGO", &self.cargo); 349 command.env("RUSTUP_TOOLCHAIN", &self.channel); 350 } 351 } 352 353 fn resolve_wasm_toolchain(root: &Path) -> Result<WasmToolchain, String> { 354 let wasm_pack = resolve_required_path_tool("wasm-pack")?; 355 let rust = resolve_rust_toolchain(root)?; 356 Ok(WasmToolchain { wasm_pack, rust }) 357 } 358 359 pub(crate) fn resolve_rust_toolchain(root: &Path) -> Result<ResolvedRustToolchain, String> { 360 let toolchain_path = root.join("rust-toolchain.toml"); 361 let channel = read_rust_toolchain_channel(&toolchain_path)?; 362 let rustc = rustup_tool_for_channel("rustc", &channel)?; 363 let cargo = rustup_tool_for_channel("cargo", &channel)?; 364 let rustc_bin = rustc.parent().ok_or_else(|| { 365 format!( 366 "rustup resolved rustc for toolchain {channel} without a parent path: {}", 367 rustc.display() 368 ) 369 })?; 370 let cargo_bin = cargo.parent().ok_or_else(|| { 371 format!( 372 "rustup resolved cargo for toolchain {channel} without a parent path: {}", 373 cargo.display() 374 ) 375 })?; 376 if rustc_bin != cargo_bin { 377 return Err(format!( 378 "rustup resolved mismatched Rust tool paths for toolchain {channel}: rustc={}, cargo={}", 379 rustc.display(), 380 cargo.display() 381 )); 382 } 383 let bin_dir = rustc_bin.to_path_buf(); 384 ensure_wasm_target_installed(&channel)?; 385 Ok(ResolvedRustToolchain { 386 channel, 387 rustc, 388 cargo, 389 bin_dir, 390 }) 391 } 392 393 fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { 394 vec![ 395 "build", 396 spec.crate_dir, 397 "--release", 398 "--target", 399 "web", 400 "--out-name", 401 spec.out_name, 402 "--no-pack", 403 ] 404 } 405 406 fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { 407 let ignore_path = dist_dir.join(".gitignore"); 408 let contents = match fs::read_to_string(&ignore_path) { 409 Ok(contents) => contents, 410 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), 411 Err(error) => { 412 return Err(format!( 413 "failed to read wasm-pack ignore file for {}: {}: {error}", 414 spec.package_name, 415 ignore_path.display() 416 )); 417 } 418 }; 419 if contents.trim() != "*" { 420 return Err(format!( 421 "unexpected wasm-pack ignore file for {}: {}; refusing to remove it", 422 spec.package_name, 423 ignore_path.display() 424 )); 425 } 426 fs::remove_file(&ignore_path).map_err(|error| { 427 format!( 428 "failed to remove wasm-pack ignore file for {}: {}: {error}", 429 spec.package_name, 430 ignore_path.display() 431 ) 432 }) 433 } 434 435 fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { 436 match args { 437 [] => Ok(wasm_package_specs().to_vec()), 438 [flag, key] if flag == "--package" => wasm_package_specs() 439 .iter() 440 .copied() 441 .find(|spec| spec.key == key) 442 .map(|spec| vec![spec]) 443 .ok_or_else(|| format!("unknown wasm package: {key}")), 444 _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), 445 } 446 } 447 448 fn wasm_package_requires_c_compiler(spec: WasmPackageSpec) -> bool { 449 spec.key == "event_codec" 450 } 451 452 fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { 453 let path = env::var_os("PATH").ok_or_else(|| { 454 format!("missing {name}: PATH is not set; install {name} and expose it on PATH") 455 })?; 456 resolve_path_tool_from_path(name, &path) 457 } 458 459 fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { 460 let matches = executable_matches(name, path); 461 match matches.as_slice() { 462 [] => Err(format!( 463 "missing {name}: install {name} and rerun `cargo xtask generate wasm`" 464 )), 465 [tool] => Ok(tool.clone()), 466 _ => Err(format!( 467 "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", 468 matches 469 .iter() 470 .map(|path| path.display().to_string()) 471 .collect::<Vec<_>>() 472 .join(", ") 473 )), 474 } 475 } 476 477 fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { 478 let mut seen = BTreeSet::new(); 479 let mut matches = Vec::new(); 480 for dir in env::split_paths(path) { 481 let candidate = dir.join(name); 482 if !is_executable_file(&candidate) { 483 continue; 484 } 485 let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); 486 if seen.insert(key) { 487 matches.push(candidate); 488 } 489 } 490 matches 491 } 492 493 fn is_executable_file(path: &Path) -> bool { 494 if !path.is_file() { 495 return false; 496 } 497 #[cfg(unix)] 498 { 499 use std::os::unix::fs::PermissionsExt; 500 path.metadata() 501 .map(|metadata| metadata.permissions().mode() & 0o111 != 0) 502 .unwrap_or(false) 503 } 504 #[cfg(not(unix))] 505 { 506 true 507 } 508 } 509 510 fn read_rust_toolchain_channel(path: &Path) -> Result<String, String> { 511 let contents = fs::read_to_string(path) 512 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 513 rust_toolchain_channel_from(&contents) 514 } 515 516 fn rust_toolchain_channel_from(contents: &str) -> Result<String, String> { 517 let value = contents 518 .parse::<toml::Value>() 519 .map_err(|error| format!("failed to parse rust-toolchain.toml: {error}"))?; 520 let channel = value 521 .get("toolchain") 522 .and_then(|toolchain| toolchain.get("channel")) 523 .and_then(toml::Value::as_str) 524 .ok_or_else(|| "rust-toolchain.toml must define toolchain.channel".to_owned())? 525 .trim(); 526 if channel.is_empty() { 527 return Err("rust-toolchain.toml toolchain.channel must not be empty".to_owned()); 528 } 529 Ok(channel.to_owned()) 530 } 531 532 fn rustup_tool_for_channel(name: &str, channel: &str) -> Result<PathBuf, String> { 533 let output = Command::new("rustup") 534 .arg("which") 535 .arg("--toolchain") 536 .arg(channel) 537 .arg(name) 538 .output() 539 .map_err(|error| { 540 format!( 541 "failed to resolve {name} for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" 542 ) 543 })?; 544 if !output.status.success() { 545 let stderr = String::from_utf8_lossy(&output.stderr); 546 return Err(format!( 547 "failed to resolve {name} for Rust toolchain {channel}: {}; run `rustup toolchain install {channel}`", 548 stderr.trim() 549 )); 550 } 551 let path = String::from_utf8(output.stdout) 552 .map_err(|error| format!("rustup emitted non-UTF-8 {name} path: {error}"))?; 553 let trimmed = path.trim(); 554 if trimmed.is_empty() { 555 return Err(format!( 556 "rustup returned an empty {name} path for Rust toolchain {channel}" 557 )); 558 } 559 Ok(PathBuf::from(trimmed)) 560 } 561 562 fn ensure_wasm_target_installed(channel: &str) -> Result<(), String> { 563 let output = Command::new("rustup") 564 .args(rustup_target_list_args(channel)) 565 .output() 566 .map_err(|error| { 567 format!( 568 "failed to verify {WASM_TARGET} target for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" 569 ) 570 })?; 571 if !output.status.success() { 572 let stderr = String::from_utf8_lossy(&output.stderr); 573 return Err(format!( 574 "failed to verify {WASM_TARGET} target for Rust toolchain {channel}: {}; run `rustup target add {WASM_TARGET} --toolchain {channel}`", 575 stderr.trim() 576 )); 577 } 578 let stdout = String::from_utf8_lossy(&output.stdout); 579 validate_target_list(&stdout, WASM_TARGET, channel) 580 } 581 582 fn rustup_target_list_args(channel: &str) -> [&str; 5] { 583 ["target", "list", "--installed", "--toolchain", channel] 584 } 585 586 fn validate_target_list(output: &str, target: &str, channel: &str) -> Result<(), String> { 587 if target_list_contains(output, target) { 588 Ok(()) 589 } else { 590 Err(format!( 591 "missing Rust target {target} for Rust toolchain {channel}: run `rustup target add {target} --toolchain {channel}`" 592 )) 593 } 594 } 595 596 fn target_list_contains(output: &str, target: &str) -> bool { 597 output.lines().any(|line| line.trim() == target) 598 } 599 600 #[derive(Debug)] 601 struct WasmCCompiler { 602 path: PathBuf, 603 cflags: ResolvedWasmCFlags, 604 } 605 606 impl WasmCCompiler { 607 fn apply_to_command(&self, command: &mut Command) { 608 command.env(WASM_C_COMPILER_ENV, &self.path); 609 command.env(WASM_CFLAGS_ENV, &self.cflags.value); 610 } 611 } 612 613 #[derive(Clone, Debug, Eq, PartialEq)] 614 struct ResolvedWasmCFlags { 615 value: String, 616 args: Vec<String>, 617 } 618 619 fn resolve_wasm_c_compiler() -> Result<WasmCCompiler, String> { 620 let path = env::var_os("PATH").unwrap_or_default(); 621 let explicit = env::var_os(WASM_C_COMPILER_ENV); 622 let cflags = resolve_wasm_cflags(env::var_os(WASM_CFLAGS_ENV).as_deref())?; 623 let candidates = wasm_c_compiler_candidates(&path); 624 resolve_wasm_c_compiler_with( 625 explicit.as_deref(), 626 &path, 627 candidates, 628 cflags, 629 verify_wasm_c_compiler, 630 ) 631 } 632 633 fn resolve_wasm_c_compiler_with<F>( 634 explicit: Option<&OsStr>, 635 path: &OsStr, 636 candidates: Vec<PathBuf>, 637 cflags: ResolvedWasmCFlags, 638 verify: F, 639 ) -> Result<WasmCCompiler, String> 640 where 641 F: Fn(&Path, &ResolvedWasmCFlags) -> Result<(), String>, 642 { 643 if let Some(explicit) = explicit { 644 let compiler = resolve_explicit_wasm_c_compiler(explicit, path)?; 645 verify(&compiler, &cflags).map_err(|error| { 646 format!( 647 "{WASM_C_COMPILER_ENV} does not name a WASM-capable C compiler: {}; {error}", 648 compiler.display() 649 ) 650 })?; 651 return Ok(WasmCCompiler { 652 path: compiler, 653 cflags, 654 }); 655 } 656 657 let mut checked = Vec::new(); 658 for candidate in candidates { 659 if !is_executable_file(&candidate) { 660 continue; 661 } 662 checked.push(candidate.display().to_string()); 663 if verify(&candidate, &cflags).is_ok() { 664 return Ok(WasmCCompiler { 665 path: candidate, 666 cflags, 667 }); 668 } 669 } 670 let checked = if checked.is_empty() { 671 "none".to_owned() 672 } else { 673 checked.join(", ") 674 }; 675 Err(format!( 676 "missing suitable WASM C compiler for {WASM_TARGET}: set {WASM_C_COMPILER_ENV} to a clang-style compiler that accepts `{WASM_C_TARGET_ARG}`; checked candidates: {checked}" 677 )) 678 } 679 680 fn resolve_wasm_cflags(value: Option<&OsStr>) -> Result<ResolvedWasmCFlags, String> { 681 let raw = match value { 682 Some(value) => Some( 683 value 684 .to_str() 685 .ok_or_else(|| format!("{WASM_CFLAGS_ENV} must be valid UTF-8 compiler flags"))?, 686 ), 687 None => None, 688 }; 689 resolve_wasm_cflags_from_str(raw) 690 } 691 692 fn resolve_wasm_cflags_from_str(value: Option<&str>) -> Result<ResolvedWasmCFlags, String> { 693 let raw = value.unwrap_or(""); 694 let words = parse_cflags_words(raw)?; 695 let mut has_required_target = false; 696 for index in 0..words.len() { 697 let word = &words[index]; 698 if word == WASM_C_TARGET_ARG { 699 has_required_target = true; 700 } else if wasm_cflags_target_conflict(index, &words) { 701 return Err(format!( 702 "{WASM_CFLAGS_ENV} contains unsupported target flag `{}`; use `{WASM_C_TARGET_ARG}` or omit the target so xtask can prepend it", 703 wasm_cflags_target_display(index, &words) 704 )); 705 } 706 } 707 let value = if has_required_target { 708 raw.to_owned() 709 } else { 710 let trimmed = raw.trim(); 711 if trimmed.is_empty() { 712 WASM_C_TARGET_ARG.to_owned() 713 } else { 714 format!("{WASM_C_TARGET_ARG} {trimmed}") 715 } 716 }; 717 let args = parse_cflags_words(&value)?; 718 Ok(ResolvedWasmCFlags { value, args }) 719 } 720 721 fn wasm_cflags_target_conflict(index: usize, words: &[String]) -> bool { 722 let word = &words[index]; 723 word == "--target" 724 || word == "-target" 725 || word.starts_with("--target=") 726 || word.starts_with("-target=") 727 } 728 729 fn wasm_cflags_target_display(index: usize, words: &[String]) -> String { 730 let word = &words[index]; 731 if matches!(word.as_str(), "--target" | "-target") 732 && let Some(target) = words.get(index + 1) 733 { 734 return format!("{word} {target}"); 735 } 736 word.clone() 737 } 738 739 fn parse_cflags_words(value: &str) -> Result<Vec<String>, String> { 740 let mut words = Vec::new(); 741 let mut current = String::new(); 742 let mut quote = CFlagsQuote::None; 743 let mut in_word = false; 744 let mut chars = value.chars(); 745 while let Some(character) = chars.next() { 746 match quote { 747 CFlagsQuote::None => match character { 748 character if character.is_whitespace() => { 749 if in_word { 750 words.push(std::mem::take(&mut current)); 751 in_word = false; 752 } 753 } 754 '\'' => { 755 in_word = true; 756 quote = CFlagsQuote::Single; 757 } 758 '"' => { 759 in_word = true; 760 quote = CFlagsQuote::Double; 761 } 762 '\\' => { 763 in_word = true; 764 let Some(escaped) = chars.next() else { 765 return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); 766 }; 767 current.push(escaped); 768 } 769 character => { 770 in_word = true; 771 current.push(character); 772 } 773 }, 774 CFlagsQuote::Single => { 775 if character == '\'' { 776 quote = CFlagsQuote::None; 777 } else { 778 current.push(character); 779 } 780 } 781 CFlagsQuote::Double => { 782 if character == '"' { 783 quote = CFlagsQuote::None; 784 } else if character == '\\' { 785 let Some(escaped) = chars.next() else { 786 return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); 787 }; 788 current.push(escaped); 789 } else { 790 current.push(character); 791 } 792 } 793 } 794 } 795 match quote { 796 CFlagsQuote::None => { 797 if in_word { 798 words.push(current); 799 } 800 Ok(words) 801 } 802 CFlagsQuote::Single => Err(format!( 803 "{WASM_CFLAGS_ENV} contains an unterminated ' quote" 804 )), 805 CFlagsQuote::Double => Err(format!( 806 "{WASM_CFLAGS_ENV} contains an unterminated \" quote" 807 )), 808 } 809 } 810 811 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 812 enum CFlagsQuote { 813 None, 814 Single, 815 Double, 816 } 817 818 fn resolve_explicit_wasm_c_compiler(value: &OsStr, path: &OsStr) -> Result<PathBuf, String> { 819 if value.is_empty() { 820 return Err(format!( 821 "{WASM_C_COMPILER_ENV} is set but empty; set it to a compiler path that accepts `{WASM_C_TARGET_ARG}`" 822 )); 823 } 824 let candidate = PathBuf::from(value); 825 if candidate.components().count() > 1 { 826 if is_executable_file(&candidate) { 827 Ok(candidate) 828 } else { 829 Err(format!( 830 "{WASM_C_COMPILER_ENV} is not executable: {}", 831 candidate.display() 832 )) 833 } 834 } else { 835 first_executable_match(value, path).ok_or_else(|| { 836 format!( 837 "{WASM_C_COMPILER_ENV} command was not found on PATH: {}", 838 value.to_string_lossy() 839 ) 840 }) 841 } 842 } 843 844 fn first_executable_match(name: &OsStr, path: &OsStr) -> Option<PathBuf> { 845 for dir in env::split_paths(path) { 846 let candidate = dir.join(name); 847 if is_executable_file(&candidate) { 848 return Some(candidate); 849 } 850 } 851 None 852 } 853 854 fn wasm_c_compiler_candidates(path: &OsStr) -> Vec<PathBuf> { 855 let mut seen = BTreeSet::new(); 856 let mut candidates = Vec::new(); 857 for name in ["wasm32-unknown-unknown-clang", "clang"] { 858 for candidate in executable_matches(name, path) { 859 let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); 860 if seen.insert(key) { 861 candidates.push(candidate); 862 } 863 } 864 } 865 candidates 866 } 867 868 fn verify_wasm_c_compiler(path: &Path, cflags: &ResolvedWasmCFlags) -> Result<(), String> { 869 let tempdir = tempfile::tempdir() 870 .map_err(|error| format!("failed to create C probe tempdir: {error}"))?; 871 let source_path = tempdir.path().join("wasm_probe.c"); 872 let object_path = tempdir.path().join("wasm_probe.o"); 873 fs::write( 874 &source_path, 875 "int radroots_wasm_probe(void) { return 0; }\n", 876 ) 877 .map_err(|error| format!("failed to write C compiler probe: {error}"))?; 878 let output = Command::new(path) 879 .args(&cflags.args) 880 .arg("-c") 881 .arg(&source_path) 882 .arg("-o") 883 .arg(&object_path) 884 .stdout(Stdio::piped()) 885 .stderr(Stdio::piped()) 886 .output() 887 .map_err(|error| format!("failed to run {}: {error}", path.display()))?; 888 if output.status.success() && object_path.is_file() { 889 return Ok(()); 890 } 891 let stderr = String::from_utf8_lossy(&output.stderr); 892 Err(format!( 893 "{} rejected `{WASM_CFLAGS_ENV}={}` for {}: {}", 894 path.display(), 895 cflags.value, 896 WASM_TARGET, 897 stderr.trim() 898 )) 899 } 900 901 fn prepend_path(command: &mut Command, prefix: &Path) { 902 let existing = env::var_os("PATH").unwrap_or_default(); 903 let mut paths = vec![prefix.to_path_buf()]; 904 paths.extend(env::split_paths(&existing)); 905 if let Ok(joined) = env::join_paths(paths) { 906 command.env("PATH", joined); 907 } 908 } 909 910 #[cfg(test)] 911 mod tests { 912 use std::{ 913 env, 914 ffi::OsStr, 915 fs, 916 path::{Path, PathBuf}, 917 process::Command, 918 time::{SystemTime, UNIX_EPOCH}, 919 }; 920 921 use crate::build_control::Mode; 922 use crate::sdk_generation::package_matrix::wasm_package_specs; 923 924 use super::{ 925 ResolvedWasmCFlags, WASM_C_COMPILER_ENV, WASM_C_TARGET_ARG, WASM_CFLAGS_ENV, WASM_TARGET, 926 WasmCCompiler, remove_wasm_pack_gitignore, resolve_path_tool_from_path, 927 resolve_wasm_c_compiler_with, resolve_wasm_cflags_from_str, rust_toolchain_channel_from, 928 rustup_target_list_args, selected_specs, target_list_contains, validate_target_list, 929 wasm_pack_args, wasm_package_requires_c_compiler, 930 }; 931 932 #[test] 933 #[ignore = "requires wasm-pack, the pinned WASM target, and a WASM C compiler"] 934 fn canonical_wasm_generation_round_trips_through_an_explicit_consumer() { 935 let consumer = tempfile::TempDir::new().expect("consumer fixture"); 936 for spec in wasm_package_specs() { 937 let package_dir = consumer.path().join(spec.package_dir); 938 fs::create_dir_all(&package_dir).expect("package directory"); 939 fs::write( 940 package_dir.join("package.json"), 941 format!( 942 "{{\n \"name\": \"{}\",\n \"version\": \"0.1.0\"\n}}\n", 943 spec.package_name 944 ), 945 ) 946 .expect("package manifest"); 947 } 948 let source = crate::workspace_root(); 949 super::generate(&source, consumer.path(), &[], Mode::Write).expect("WASM generation"); 950 super::generate(&source, consumer.path(), &[], Mode::Check).expect("fresh WASM outputs"); 951 } 952 953 #[test] 954 fn selects_all_specs_by_default() { 955 assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); 956 } 957 958 #[test] 959 fn selects_one_spec_by_key() { 960 let specs = selected_specs(&["--package".to_owned(), "replica_store".to_owned()]) 961 .expect("replica store spec"); 962 assert_eq!(specs[0].package_name, "@radroots/replica-store-wasm"); 963 } 964 965 #[test] 966 fn rejects_unknown_spec_key() { 967 assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); 968 } 969 970 #[test] 971 fn wasm_pack_arguments_disable_package_manifest_generation() { 972 let args = wasm_pack_args(wasm_package_specs()[0]); 973 assert!(args.contains(&"--no-pack")); 974 } 975 976 #[test] 977 fn removes_wasm_pack_generated_gitignore() { 978 let root = test_root("wasm_pack_gitignore"); 979 let dist_dir = root.join("dist"); 980 fs::create_dir_all(&dist_dir).expect("create dist"); 981 fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore"); 982 983 remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore"); 984 985 assert!(!dist_dir.join(".gitignore").exists()); 986 let _ = fs::remove_dir_all(root); 987 } 988 989 #[test] 990 fn refuses_unexpected_wasm_pack_gitignore_contents() { 991 let root = test_root("custom_gitignore"); 992 let dist_dir = root.join("dist"); 993 fs::create_dir_all(&dist_dir).expect("create dist"); 994 fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore"); 995 996 let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]) 997 .expect_err("custom ignore rejected"); 998 999 assert!(error.contains("unexpected wasm-pack ignore file")); 1000 let _ = fs::remove_dir_all(root); 1001 } 1002 1003 #[test] 1004 fn path_tool_resolution_reports_missing_tools() { 1005 let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) 1006 .expect_err("missing"); 1007 assert!(error.contains("missing wasm-pack")); 1008 } 1009 1010 #[test] 1011 fn path_tool_resolution_reports_ambiguous_tools() { 1012 let root = test_root("ambiguous_wasm_pack"); 1013 let first = root.join("first"); 1014 let second = root.join("second"); 1015 fs::create_dir_all(&first).expect("create first dir"); 1016 fs::create_dir_all(&second).expect("create second dir"); 1017 write_executable(first.join("wasm-pack")); 1018 write_executable(second.join("wasm-pack")); 1019 let path = env::join_paths([first, second]).expect("join path"); 1020 1021 let error = 1022 resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); 1023 1024 assert!(error.contains("ambiguous wasm-pack")); 1025 let _ = fs::remove_dir_all(root); 1026 } 1027 1028 #[test] 1029 fn target_list_parser_requires_exact_target() { 1030 assert!(target_list_contains( 1031 "aarch64-apple-darwin\nwasm32-unknown-unknown\n", 1032 "wasm32-unknown-unknown" 1033 )); 1034 assert!(!target_list_contains( 1035 "wasm32-unknown-emscripten\n", 1036 "wasm32-unknown-unknown" 1037 )); 1038 } 1039 1040 #[test] 1041 fn rustup_target_list_args_are_bound_to_selected_toolchain() { 1042 assert_eq!( 1043 rustup_target_list_args("1.97.0"), 1044 ["target", "list", "--installed", "--toolchain", "1.97.0"] 1045 ); 1046 } 1047 1048 #[test] 1049 fn missing_wasm_target_error_names_selected_toolchain() { 1050 let error = validate_target_list("aarch64-apple-darwin\n", WASM_TARGET, "1.97.0") 1051 .expect_err("missing target"); 1052 1053 assert!(error.contains("wasm32-unknown-unknown")); 1054 assert!(error.contains("--toolchain 1.97.0")); 1055 } 1056 1057 #[test] 1058 fn parses_rust_toolchain_channel() { 1059 let channel = rust_toolchain_channel_from( 1060 r#"[toolchain] 1061 channel = "1.97.0" 1062 "#, 1063 ) 1064 .expect("channel"); 1065 1066 assert_eq!(channel, "1.97.0"); 1067 } 1068 1069 #[test] 1070 fn rejects_missing_wasm_c_compiler() { 1071 let error = resolve_wasm_c_compiler_with( 1072 None, 1073 OsStr::new(""), 1074 Vec::new(), 1075 wasm_cflags(None), 1076 |_, _| Ok(()), 1077 ) 1078 .expect_err("missing compiler"); 1079 1080 assert!(error.contains(WASM_C_COMPILER_ENV)); 1081 assert!(error.contains(WASM_C_TARGET_ARG)); 1082 } 1083 1084 #[test] 1085 fn rejects_unsuitable_wasm_c_compiler_candidates() { 1086 let root = test_root("unsuitable_wasm_c_compiler"); 1087 fs::create_dir_all(&root).expect("create root"); 1088 let compiler = root.join("clang"); 1089 write_executable(compiler.clone()); 1090 1091 let error = resolve_wasm_c_compiler_with( 1092 None, 1093 OsStr::new(""), 1094 vec![compiler], 1095 wasm_cflags(None), 1096 |_, _| Err("target unsupported".to_owned()), 1097 ) 1098 .expect_err("unsuitable compiler"); 1099 1100 assert!(error.contains("missing suitable WASM C compiler")); 1101 assert!(error.contains(WASM_C_COMPILER_ENV)); 1102 let _ = fs::remove_dir_all(root); 1103 } 1104 1105 #[test] 1106 fn accepts_verified_wasm_c_compiler_candidate() { 1107 let root = test_root("verified_wasm_c_compiler"); 1108 fs::create_dir_all(&root).expect("create root"); 1109 let compiler = root.join("clang"); 1110 write_executable(compiler.clone()); 1111 1112 let resolved = resolve_wasm_c_compiler_with( 1113 None, 1114 OsStr::new(""), 1115 vec![compiler.clone()], 1116 wasm_cflags(Some("-O2")), 1117 |path: &Path, cflags: &ResolvedWasmCFlags| { 1118 assert_eq!(path, compiler); 1119 assert_eq!(cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); 1120 Ok(()) 1121 }, 1122 ) 1123 .expect("compiler"); 1124 1125 assert_eq!(resolved.path, compiler); 1126 assert_eq!(resolved.cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); 1127 let _ = fs::remove_dir_all(root); 1128 } 1129 1130 #[test] 1131 fn absent_wasm_cflags_resolve_to_required_target() { 1132 let resolved = resolve_wasm_cflags_from_str(None).expect("cflags"); 1133 1134 assert_eq!(resolved.value, WASM_C_TARGET_ARG); 1135 assert_eq!(resolved.args, [WASM_C_TARGET_ARG.to_owned()]); 1136 } 1137 1138 #[test] 1139 fn present_valid_wasm_cflags_preserve_additional_flags() { 1140 let value = "-O2 --target=wasm32-unknown-unknown -fvisibility=hidden"; 1141 let resolved = resolve_wasm_cflags_from_str(Some(value)).expect("cflags"); 1142 1143 assert_eq!(resolved.value, value); 1144 assert_eq!( 1145 resolved.args, 1146 [ 1147 "-O2".to_owned(), 1148 WASM_C_TARGET_ARG.to_owned(), 1149 "-fvisibility=hidden".to_owned() 1150 ] 1151 ); 1152 } 1153 1154 #[test] 1155 fn missing_target_wasm_cflags_prepend_required_target() { 1156 let resolved = resolve_wasm_cflags_from_str(Some(" -O2 -fPIC ")).expect("target prepended"); 1157 1158 assert_eq!(resolved.value, format!("{WASM_C_TARGET_ARG} -O2 -fPIC")); 1159 assert_eq!( 1160 resolved.args, 1161 [ 1162 WASM_C_TARGET_ARG.to_owned(), 1163 "-O2".to_owned(), 1164 "-fPIC".to_owned() 1165 ] 1166 ); 1167 } 1168 1169 #[test] 1170 fn conflicting_target_wasm_cflags_are_rejected() { 1171 let error = resolve_wasm_cflags_from_str(Some("--target=wasm32-wasip1 -O2")) 1172 .expect_err("conflicting target"); 1173 1174 assert!(error.contains(WASM_CFLAGS_ENV)); 1175 assert!(error.contains("--target=wasm32-wasip1")); 1176 assert!(error.contains(WASM_C_TARGET_ARG)); 1177 } 1178 1179 #[test] 1180 fn separated_target_wasm_cflags_are_rejected() { 1181 let error = resolve_wasm_cflags_from_str(Some("--target wasm32-unknown-unknown")) 1182 .expect_err("separated target"); 1183 1184 assert!(error.contains("--target wasm32-unknown-unknown")); 1185 assert!(error.contains(WASM_C_TARGET_ARG)); 1186 } 1187 1188 #[test] 1189 fn wasm_c_compiler_command_uses_resolved_cflags() { 1190 let compiler = WasmCCompiler { 1191 path: PathBuf::from("clang"), 1192 cflags: wasm_cflags(Some("-O2")), 1193 }; 1194 let mut command = Command::new("wasm-pack"); 1195 1196 compiler.apply_to_command(&mut command); 1197 1198 assert_eq!( 1199 command_env(&command, WASM_C_COMPILER_ENV), 1200 Some(std::ffi::OsString::from("clang")) 1201 ); 1202 assert_eq!( 1203 command_env(&command, WASM_CFLAGS_ENV), 1204 Some(std::ffi::OsString::from(format!("{WASM_C_TARGET_ARG} -O2"))) 1205 ); 1206 } 1207 1208 #[test] 1209 fn event_codec_wasm_requires_c_compiler() { 1210 assert!(wasm_package_requires_c_compiler(wasm_package_specs()[0])); 1211 assert!(!wasm_package_requires_c_compiler(wasm_package_specs()[1])); 1212 } 1213 1214 fn wasm_cflags(value: Option<&str>) -> ResolvedWasmCFlags { 1215 resolve_wasm_cflags_from_str(value).expect("resolved cflags") 1216 } 1217 1218 fn command_env(command: &Command, name: &str) -> Option<std::ffi::OsString> { 1219 command.get_envs().find_map(|(key, value)| { 1220 if key == OsStr::new(name) { 1221 value.map(std::ffi::OsStr::to_os_string) 1222 } else { 1223 None 1224 } 1225 }) 1226 } 1227 1228 fn write_executable(path: PathBuf) { 1229 fs::write(&path, "#!/bin/sh\n").expect("write executable"); 1230 #[cfg(unix)] 1231 { 1232 use std::os::unix::fs::PermissionsExt; 1233 let mut permissions = fs::metadata(&path).expect("metadata").permissions(); 1234 permissions.set_mode(0o755); 1235 fs::set_permissions(&path, permissions).expect("set executable permissions"); 1236 } 1237 } 1238 1239 fn test_root(name: &str) -> PathBuf { 1240 let stamp = SystemTime::now() 1241 .duration_since(UNIX_EPOCH) 1242 .expect("system time") 1243 .as_nanos(); 1244 env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) 1245 } 1246 }