Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs
T
lhk229 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
清理测试编译warning (#509)
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>
2026-10-01 15:29:23 +08:00

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"
);
}
}