55375b1406
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m30s
Project CI / Backend tests (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m12s
Project CI / Native shell tests (push) Successful in 5m49s
Project CI / Frontend tests (push) Successful in 2m18s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m50s
Project CI / AI game creator shell web tests (push) Failing after 2m11s
Project CI / Repository checks (push) Successful in 2m55s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/509 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
686 lines
24 KiB
Rust
686 lines
24 KiB
Rust
//! 客户端拥有的 Web 工具链;模型只读取版本和状态,不读取宿主目录或环境。
|
|
mod web_creation;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::fs;
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::time::{Duration, Instant};
|
|
#[cfg(not(test))]
|
|
pub(crate) use web_creation::preflight_web_game_creation;
|
|
pub(crate) use web_creation::{
|
|
host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at,
|
|
};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
const SCHEMA: &str = "agc-node-runtime.v1";
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const MAX_PROBE_BYTES: u64 = 4096;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct NodeRuntime {
|
|
pub node: PathBuf,
|
|
pub npm_cli: PathBuf,
|
|
pub safe_path: OsString,
|
|
pub source: &'static str,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct RuntimeManifest {
|
|
schema_version: String,
|
|
platform: String,
|
|
arch: String,
|
|
node_version: String,
|
|
npm_version: String,
|
|
files: BTreeMap<String, String>,
|
|
}
|
|
|
|
fn native_platform() -> &'static str {
|
|
if cfg!(windows) {
|
|
"win32"
|
|
} else if cfg!(target_os = "macos") {
|
|
"darwin"
|
|
} else {
|
|
std::env::consts::OS
|
|
}
|
|
}
|
|
|
|
fn native_arch() -> &'static str {
|
|
if cfg!(target_arch = "x86_64") {
|
|
"x64"
|
|
} else if cfg!(target_arch = "aarch64") {
|
|
"arm64"
|
|
} else {
|
|
std::env::consts::ARCH
|
|
}
|
|
}
|
|
|
|
fn executable_name() -> &'static str {
|
|
if cfg!(windows) {
|
|
"node.exe"
|
|
} else {
|
|
"node"
|
|
}
|
|
}
|
|
|
|
fn bundle_directory(executable: &Path) -> Option<PathBuf> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let macos = executable.parent()?;
|
|
let contents = macos.parent()?;
|
|
if macos.file_name()? != "MacOS"
|
|
|| contents.file_name()? != "Contents"
|
|
|| contents.parent()?.extension()? != "app"
|
|
{
|
|
return None;
|
|
}
|
|
Some(contents.join("Resources/game-runtime/node"))
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
Some(executable.parent()?.join("game-runtime/node"))
|
|
}
|
|
}
|
|
|
|
fn safe_directories(root: &Path, path: &OsStr) -> Vec<PathBuf> {
|
|
let mut seen = BTreeSet::new();
|
|
std::env::split_paths(path)
|
|
.filter_map(|path| {
|
|
if !path.is_absolute() {
|
|
return None;
|
|
}
|
|
let path = path.canonicalize().ok()?;
|
|
if !path.is_dir() || path.starts_with(root) || !seen.insert(path.clone()) {
|
|
return None;
|
|
}
|
|
Some(path)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn command_path(path: PathBuf) -> PathBuf {
|
|
#[cfg(windows)]
|
|
{
|
|
let value = path.to_string_lossy();
|
|
if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
|
|
return PathBuf::from(format!(r"\\{unc}"));
|
|
}
|
|
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
path
|
|
}
|
|
}
|
|
|
|
fn diagnostic_path_from(root: &Path, path: &OsStr) -> OsString {
|
|
let Ok(root) = root.canonicalize() else {
|
|
return OsString::new();
|
|
};
|
|
// 缺失/损坏的随包运行时不能通过继承 PATH 静默变成系统 Node。
|
|
// 其余绝对、项目外目录保留给 PowerShell、Git 等诊断工具。
|
|
let directories = safe_directories(&root, path)
|
|
.into_iter()
|
|
.filter(|directory| {
|
|
!["node", "npm", "npx"].iter().any(|name| {
|
|
["", ".exe", ".com", ".cmd", ".bat", ".ps1"]
|
|
.iter()
|
|
.any(|suffix| directory.join(format!("{name}{suffix}")).is_file())
|
|
})
|
|
});
|
|
let directories = directories.map(command_path);
|
|
std::env::join_paths(directories).unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn safe_diagnostic_path(root: &Path) -> OsString {
|
|
diagnostic_path_from(root, &std::env::var_os("PATH").unwrap_or_default())
|
|
}
|
|
|
|
fn runtime_from_paths(
|
|
root: &Path,
|
|
node: PathBuf,
|
|
npm_cli: PathBuf,
|
|
source: &'static str,
|
|
path: &OsStr,
|
|
) -> Result<NodeRuntime, String> {
|
|
let node = node.canonicalize().map_err(|_| "node-runtime-missing")?;
|
|
let npm_cli = npm_cli.canonicalize().map_err(|_| "npm-runtime-missing")?;
|
|
if !node.is_file() || !npm_cli.is_file() || node.starts_with(root) || npm_cli.starts_with(root)
|
|
{
|
|
return Err("node-runtime-untrusted".into());
|
|
}
|
|
let mut directories = vec![node.parent().ok_or("node-runtime-invalid")?.to_path_buf()];
|
|
directories.extend(safe_directories(root, path));
|
|
let directories = directories
|
|
.into_iter()
|
|
.map(command_path)
|
|
.collect::<Vec<_>>();
|
|
Ok(NodeRuntime {
|
|
node: command_path(node),
|
|
npm_cli: command_path(npm_cli),
|
|
safe_path: std::env::join_paths(directories).map_err(|_| "node-runtime-path-invalid")?,
|
|
source,
|
|
})
|
|
}
|
|
|
|
fn collect_bundle_files(
|
|
root: &Path,
|
|
directory: &Path,
|
|
files: &mut BTreeSet<String>,
|
|
) -> Result<(), String> {
|
|
for entry in fs::read_dir(directory).map_err(|_| "node-runtime-integrity-failed")? {
|
|
let entry = entry.map_err(|_| "node-runtime-integrity-failed")?;
|
|
let kind = entry
|
|
.file_type()
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
if kind.is_symlink() {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
if kind.is_dir() {
|
|
collect_bundle_files(root, &entry.path(), files)?;
|
|
} else if kind.is_file() {
|
|
let path = entry
|
|
.path()
|
|
.strip_prefix(root)
|
|
.map_err(|_| "node-runtime-integrity-failed")?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if path != "manifest.json" {
|
|
files.insert(path);
|
|
}
|
|
if files.len() > 20_000 {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
} else {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_bundle(directory: &Path) -> Result<(), String> {
|
|
if fs::symlink_metadata(directory)
|
|
.map_err(|_| "node-runtime-bundle-missing")?
|
|
.file_type()
|
|
.is_symlink()
|
|
{
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
let file = fs::File::open(directory.join("manifest.json"))
|
|
.map_err(|_| "node-runtime-manifest-missing")?;
|
|
let mut bytes = Vec::new();
|
|
file.take(4 * 1024 * 1024 + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|_| "node-runtime-manifest-invalid")?;
|
|
if bytes.len() > 4 * 1024 * 1024 {
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let manifest: RuntimeManifest =
|
|
serde_json::from_slice(&bytes).map_err(|_| "node-runtime-manifest-invalid")?;
|
|
if manifest.schema_version != SCHEMA
|
|
|| manifest.platform != native_platform()
|
|
|| manifest.arch != native_arch()
|
|
|| !valid_version(&manifest.node_version)
|
|
|| !valid_version(&manifest.npm_version)
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
for required in [
|
|
executable_name(),
|
|
"node_modules/npm/LICENSE",
|
|
"node_modules/npm/package.json",
|
|
"node_modules/npm/bin/npm-cli.js",
|
|
if cfg!(windows) { "npm.cmd" } else { "npm" },
|
|
] {
|
|
if !manifest.files.contains_key(required) {
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
}
|
|
if !manifest.files.contains_key("NODE-LICENSE")
|
|
&& !manifest.files.contains_key("NODE-LICENSE.rtf")
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let mut actual = BTreeSet::new();
|
|
collect_bundle_files(directory, directory, &mut actual)?;
|
|
if actual != manifest.files.keys().cloned().collect() {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
for (relative, expected) in manifest.files {
|
|
if relative.contains('\\')
|
|
|| relative.contains(':')
|
|
|| relative
|
|
.split('/')
|
|
.any(|part| part.is_empty() || part == "." || part == "..")
|
|
|| expected.len() != 64
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let mut file = fs::File::open(directory.join(relative))
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
let mut digest = Sha256::new();
|
|
let mut buffer = [0u8; 64 * 1024];
|
|
loop {
|
|
let count = file
|
|
.read(&mut buffer)
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
digest.update(&buffer[..count]);
|
|
}
|
|
if format!("{:x}", digest.finalize()) != expected {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_at(
|
|
root: &Path,
|
|
bundle: Option<&Path>,
|
|
development: bool,
|
|
path: &OsStr,
|
|
) -> Result<NodeRuntime, String> {
|
|
let root = root
|
|
.canonicalize()
|
|
.map_err(|_| "project-root-unavailable")?;
|
|
if let Some(bundle) = bundle.filter(|bundle| bundle.exists()) {
|
|
validate_bundle(bundle)?;
|
|
return runtime_from_paths(
|
|
&root,
|
|
bundle.join(executable_name()),
|
|
bundle.join("node_modules/npm/bin/npm-cli.js"),
|
|
"bundled",
|
|
path,
|
|
);
|
|
}
|
|
if !development {
|
|
return Err("node-runtime-bundle-missing".into());
|
|
}
|
|
let directories = safe_directories(&root, path);
|
|
for directory in &directories {
|
|
let candidate = directory.join(executable_name());
|
|
let Ok(node) = candidate.canonicalize() else {
|
|
continue;
|
|
};
|
|
if !node.is_file() || node.starts_with(&root) {
|
|
continue;
|
|
}
|
|
let parent = node.parent().ok_or("node-runtime-invalid")?;
|
|
let mut npm_candidates = vec![
|
|
parent.join("node_modules/npm/bin/npm-cli.js"),
|
|
parent.join("../lib/node_modules/npm/bin/npm-cli.js"),
|
|
parent.join("../share/nodejs/npm/bin/npm-cli.js"),
|
|
];
|
|
for directory in &directories {
|
|
if let Ok(npm) = directory.join("npm").canonicalize() {
|
|
if npm.file_name() == Some(OsStr::new("npm-cli.js")) {
|
|
npm_candidates.push(npm);
|
|
}
|
|
}
|
|
}
|
|
if let Some(npm_cli) = npm_candidates
|
|
.into_iter()
|
|
.find(|candidate| candidate.is_file())
|
|
{
|
|
return runtime_from_paths(&root, node, npm_cli, "development", path);
|
|
}
|
|
}
|
|
Err("node-npm-runtime-missing".into())
|
|
}
|
|
|
|
pub(crate) fn resolve_node_runtime(root: &Path) -> Result<NodeRuntime, String> {
|
|
let executable = std::env::current_exe().map_err(|_| "node-runtime-location-unavailable")?;
|
|
resolve_at(
|
|
root,
|
|
bundle_directory(&executable).as_deref(),
|
|
cfg!(debug_assertions),
|
|
&std::env::var_os("PATH").unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
fn valid_version(value: &str) -> bool {
|
|
let value = value.strip_prefix('v').unwrap_or(value);
|
|
value.len() <= 40
|
|
&& value.split('.').count() == 3
|
|
&& value
|
|
.split('.')
|
|
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
|
|
}
|
|
|
|
async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result<String, String> {
|
|
// npm 初始化也会查询用户目录;显式隔离,避免依赖 Windows 后备查询。
|
|
let home = tempfile::tempdir().map_err(|_| "runtime-probe-home-unavailable")?;
|
|
let config = home.path().join("user.npmrc");
|
|
let global_config = home.path().join("global.npmrc");
|
|
fs::write(&config, b"").map_err(|_| "runtime-probe-home-unavailable")?;
|
|
fs::write(&global_config, b"").map_err(|_| "runtime-probe-home-unavailable")?;
|
|
let mut command = tokio::process::Command::new(&runtime.node);
|
|
command
|
|
.env_clear()
|
|
.env("PATH", &runtime.safe_path)
|
|
.current_dir(home.path())
|
|
.env("HOME", home.path())
|
|
.env("USERPROFILE", home.path())
|
|
.env("APPDATA", home.path())
|
|
.env("LOCALAPPDATA", home.path())
|
|
.env("npm_config_userconfig", &config)
|
|
.env("npm_config_globalconfig", &global_config)
|
|
.env("npm_config_cache", home.path().join("npm-cache"))
|
|
.env("npm_config_update_notifier", "false")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true);
|
|
for name in ["SystemRoot", "WINDIR", "ComSpec", "TEMP", "TMP"] {
|
|
if let Some(value) = std::env::var_os(name) {
|
|
command.env(name, value);
|
|
}
|
|
}
|
|
if npm {
|
|
command.arg(&runtime.npm_cli);
|
|
}
|
|
command.arg("--version");
|
|
#[cfg(windows)]
|
|
crate::configure_windows_background_tokio_command(&mut command, true);
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|_| "runtime-version-start-failed")?;
|
|
let mut stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or("runtime-version-output-missing")?
|
|
.take(MAX_PROBE_BYTES + 1);
|
|
let mut output = Vec::new();
|
|
let result = tokio::time::timeout(PROBE_TIMEOUT, async {
|
|
stdout
|
|
.read_to_end(&mut output)
|
|
.await
|
|
.map_err(|_| "runtime-version-output-failed")?;
|
|
if output.len() as u64 > MAX_PROBE_BYTES {
|
|
return Err("runtime-version-output-too-large");
|
|
}
|
|
let status = child
|
|
.wait()
|
|
.await
|
|
.map_err(|_| "runtime-version-wait-failed")?;
|
|
if !status.success() {
|
|
return Err("runtime-version-failed");
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
if !matches!(result, Ok(Ok(()))) {
|
|
let _ = child.kill().await;
|
|
let _ = child.wait().await;
|
|
}
|
|
result
|
|
.map_err(|_| "runtime-version-timeout")?
|
|
.map_err(str::to_string)?;
|
|
let version = String::from_utf8(output)
|
|
.map_err(|_| "runtime-version-invalid")?
|
|
.trim()
|
|
.to_string();
|
|
if !valid_version(&version) {
|
|
return Err("runtime-version-invalid".into());
|
|
}
|
|
Ok(version)
|
|
}
|
|
|
|
pub(crate) async fn check_environment(root: &Path) -> Value {
|
|
let started = Instant::now();
|
|
// 独立检查可并发,不占项目锁,也不写入 manifest/revision。
|
|
let node_check = async {
|
|
let started = Instant::now();
|
|
let root = root.to_path_buf();
|
|
let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await;
|
|
let runtime = match result {
|
|
Ok(Ok(runtime)) => runtime,
|
|
Ok(Err(code)) => {
|
|
return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
Err(_) => {
|
|
return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
};
|
|
let (node, npm) = tokio::join!(
|
|
probe_version(&runtime, false),
|
|
probe_version(&runtime, true)
|
|
);
|
|
match (node, npm) {
|
|
(Ok(node), Ok(npm)) => {
|
|
json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
(Err(code), _) | (_, Err(code)) => {
|
|
json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
}
|
|
};
|
|
let browser_check = async {
|
|
let started = Instant::now();
|
|
match crate::browser::check_browser_health().await {
|
|
Ok(browser) => {
|
|
json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
Err(code) => {
|
|
json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
}
|
|
};
|
|
let (runtime, browser) = tokio::join!(node_check, browser_check);
|
|
json!({"schemaVersion":"agc-environment-check.v1","status":if runtime["status"] == "ready" && browser["status"] == "ready" {"ready"} else {"blocked"},"runtime":runtime,"browser":browser,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn bundle_fixture(directory: &Path) {
|
|
let mut files = BTreeMap::new();
|
|
for relative in [
|
|
executable_name(),
|
|
"NODE-LICENSE",
|
|
"node_modules/npm/LICENSE",
|
|
"node_modules/npm/package.json",
|
|
"node_modules/npm/bin/npm-cli.js",
|
|
if cfg!(windows) { "npm.cmd" } else { "npm" },
|
|
] {
|
|
let path = directory.join(relative);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, relative.as_bytes()).unwrap();
|
|
files.insert(
|
|
relative,
|
|
format!("{:x}", Sha256::digest(relative.as_bytes())),
|
|
);
|
|
}
|
|
fs::write(directory.join("manifest.json"), serde_json::to_vec(&json!({"schemaVersion":SCHEMA,"platform":native_platform(),"arch":native_arch(),"nodeVersion":"v24.0.0","npmVersion":"11.0.0","files":files})).unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_integrity_rejects_changed_missing_unlisted_and_wrong_architecture_files() {
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
validate_bundle(bundle.path()).unwrap();
|
|
fs::write(bundle.path().join(executable_name()), "modified").unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
bundle_fixture(bundle.path());
|
|
fs::write(bundle.path().join("unlisted.js"), "unexpected").unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
fs::remove_file(bundle.path().join("unlisted.js")).unwrap();
|
|
fs::remove_file(bundle.path().join("node_modules/npm/bin/npm-cli.js")).unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
bundle_fixture(bundle.path());
|
|
let manifest_path = bundle.path().join("manifest.json");
|
|
let mut manifest: Value =
|
|
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
|
manifest["arch"] = json!("wrong-architecture");
|
|
fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-manifest-invalid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn complete_bundle_resolves_without_system_node_and_does_not_expose_project_path() {
|
|
let project = tempfile::tempdir().unwrap();
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
let runtime = resolve_at(
|
|
project.path(),
|
|
Some(bundle.path()),
|
|
false,
|
|
project.path().as_os_str(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(runtime.source, "bundled");
|
|
assert_eq!(
|
|
runtime.node.canonicalize().unwrap(),
|
|
bundle
|
|
.path()
|
|
.join(executable_name())
|
|
.canonicalize()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(std::env::split_paths(&runtime.safe_path).count(), 1);
|
|
assert_eq!(fs::read_dir(project.path()).unwrap().count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_accepts_hashed_original_installer_rtf_license() {
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
fs::rename(
|
|
bundle.path().join("NODE-LICENSE"),
|
|
bundle.path().join("NODE-LICENSE.rtf"),
|
|
)
|
|
.unwrap();
|
|
let manifest_path = bundle.path().join("manifest.json");
|
|
let mut manifest: Value =
|
|
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
|
let digest = manifest["files"]
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("NODE-LICENSE")
|
|
.unwrap();
|
|
manifest["files"]["NODE-LICENSE.rtf"] = digest;
|
|
fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
validate_bundle(bundle.path()).unwrap();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn node_script_arguments_use_win32_paths_after_identity_validation() {
|
|
assert_eq!(
|
|
command_path(PathBuf::from(r"\\?\C:\node\npm-cli.js")),
|
|
PathBuf::from(r"C:\node\npm-cli.js")
|
|
);
|
|
assert_eq!(
|
|
command_path(PathBuf::from(r"\\?\UNC\server\node\npm-cli.js")),
|
|
PathBuf::from(r"\\server\node\npm-cli.js")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_runtime_never_falls_back_when_bundle_is_missing_or_invalid() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
assert_eq!(
|
|
resolve_at(root.path(), None, false, OsStr::new("")).unwrap_err(),
|
|
"node-runtime-bundle-missing"
|
|
);
|
|
assert_eq!(
|
|
resolve_at(root.path(), Some(bundle.path()), true, OsStr::new("")).unwrap_err(),
|
|
"node-runtime-manifest-missing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn development_runtime_rejects_relative_and_project_path_entries() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
fs::write(root.path().join(executable_name()), b"fake").unwrap();
|
|
let path = std::env::join_paths([Path::new("."), root.path()]).unwrap();
|
|
assert_eq!(
|
|
resolve_at(root.path(), None, true, &path).unwrap_err(),
|
|
"node-npm-runtime-missing"
|
|
);
|
|
assert!(!valid_version("v24.0.0\nSECRET"));
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() {
|
|
let project = tempfile::tempdir().unwrap();
|
|
let host = tempfile::tempdir().unwrap();
|
|
let system = host.path().join("system-tools");
|
|
let node = host.path().join("node");
|
|
let npm = host.path().join("npm-shim");
|
|
let npx = host.path().join("npx-shim");
|
|
for directory in [&system, &node, &npm, &npx] {
|
|
fs::create_dir(directory).unwrap();
|
|
}
|
|
fs::write(system.join("git.exe"), "diagnostic fixture").unwrap();
|
|
fs::write(node.join("node.exe"), "unvalidated node").unwrap();
|
|
fs::write(npm.join("npm.ps1"), "unvalidated npm shim").unwrap();
|
|
fs::write(npx.join("npx"), "unvalidated npx shim").unwrap();
|
|
let path =
|
|
std::env::join_paths([Path::new("."), project.path(), &node, &npm, &system, &npx])
|
|
.unwrap();
|
|
let kept: Vec<_> =
|
|
std::env::split_paths(&diagnostic_path_from(project.path(), &path)).collect();
|
|
assert_eq!(kept.len(), 1);
|
|
assert_eq!(
|
|
kept[0].canonicalize().unwrap(),
|
|
system.canonicalize().unwrap()
|
|
);
|
|
assert_eq!(fs::read_dir(project.path()).unwrap().count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires an installed development Node/npm; no network or project mutation"]
|
|
async fn real_node_npm_environment_versions() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let runtime = resolve_at(
|
|
root.path(),
|
|
None,
|
|
true,
|
|
&std::env::var_os("PATH").unwrap_or_default(),
|
|
)
|
|
.unwrap();
|
|
assert!(valid_version(
|
|
&probe_version(&runtime, false).await.unwrap()
|
|
));
|
|
assert!(valid_version(&probe_version(&runtime, true).await.unwrap()));
|
|
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0);
|
|
let fixture = root.path().join("probe.cjs");
|
|
fs::write(
|
|
&fixture,
|
|
include_str!("../tests/fixtures/environment-probe.cjs"),
|
|
)
|
|
.unwrap();
|
|
let fixture_runtime = NodeRuntime {
|
|
npm_cli: fixture,
|
|
..runtime
|
|
};
|
|
assert_eq!(
|
|
probe_version(&fixture_runtime, true).await.unwrap(),
|
|
"1.0.0"
|
|
);
|
|
}
|
|
}
|