lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }