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:
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}"))
+ }
}