fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
## 背景 切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。 ## 变更 - 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。 - 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。 - AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。 - 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。 - 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。 - 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。 - 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。 ## 兼容性影响 省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。 ## 验证 - 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。 - AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。 - 前端:182 条定向测试与 `typecheck` 通过。 - 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。 - 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
5048 lines
190 KiB
Rust
5048 lines
190 KiB
Rust
use super::*;
|
||
|
||
#[cfg(windows)]
|
||
use std::collections::HashMap;
|
||
#[cfg(windows)]
|
||
use std::time::Instant;
|
||
|
||
#[cfg(windows)]
|
||
const GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL: Duration = Duration::from_secs(300);
|
||
|
||
#[cfg(windows)]
|
||
#[derive(Clone, Copy)]
|
||
struct UserSelectedPathGrant {
|
||
is_directory: bool,
|
||
expires_at: Instant,
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
static GAME_CREATOR_USER_SELECTED_PATH_GRANTS: OnceLock<
|
||
Mutex<HashMap<String, UserSelectedPathGrant>>,
|
||
> = OnceLock::new();
|
||
|
||
#[cfg(windows)]
|
||
fn user_selected_path_grants() -> &'static Mutex<HashMap<String, UserSelectedPathGrant>> {
|
||
GAME_CREATOR_USER_SELECTED_PATH_GRANTS.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
||
let path = normalize_windows_policy_path(path);
|
||
if !path.is_absolute()
|
||
|| path
|
||
.components()
|
||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||
{
|
||
return None;
|
||
}
|
||
Some(
|
||
path.to_string_lossy()
|
||
.replace('/', "\\")
|
||
.trim_end_matches('\\')
|
||
.to_ascii_lowercase(),
|
||
)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
||
let value = path.to_string_lossy();
|
||
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
|
||
return PathBuf::from(format!(r"\\{rest}"));
|
||
}
|
||
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
||
path.to_path_buf()
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
|
||
let Some(key) = normalize_user_selected_path_key(path) else {
|
||
return;
|
||
};
|
||
let mut grants = user_selected_path_grants()
|
||
.lock()
|
||
.expect("user-selected path grants lock");
|
||
grants.retain(|_, grant| grant.expires_at > Instant::now());
|
||
grants.insert(
|
||
key,
|
||
UserSelectedPathGrant {
|
||
is_directory,
|
||
expires_at: Instant::now() + GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL,
|
||
},
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn revoke_game_creator_user_selected_path(path: &Path) {
|
||
let Some(key) = normalize_user_selected_path_key(path) else {
|
||
return;
|
||
};
|
||
user_selected_path_grants()
|
||
.lock()
|
||
.expect("user-selected path grants lock")
|
||
.remove(&key);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool {
|
||
let Some(key) = normalize_user_selected_path_key(path) else {
|
||
return false;
|
||
};
|
||
let mut grants = user_selected_path_grants()
|
||
.lock()
|
||
.expect("user-selected path grants lock");
|
||
let now = Instant::now();
|
||
grants.retain(|_, grant| grant.expires_at > now);
|
||
grants.iter().any(|(granted_key, grant)| {
|
||
if granted_key == &key {
|
||
return grant.is_directory == is_directory;
|
||
}
|
||
grant.is_directory
|
||
&& key
|
||
.strip_prefix(granted_key)
|
||
.is_some_and(|suffix| suffix.starts_with('\\'))
|
||
})
|
||
}
|
||
|
||
pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1";
|
||
/// 官方路由未选择平台目录模型时写入配置文件的占位标识。
|
||
pub(crate) const OFFICIAL_LLM_ROUTER_DEFAULT_MODEL: &str = "platform-default";
|
||
|
||
pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [
|
||
(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"),
|
||
("planner", "high"),
|
||
("orchestrator", "medium"),
|
||
("generator", "high"),
|
||
("evaluator", "high"),
|
||
("design-director", "medium"),
|
||
("design-foundation", "high"),
|
||
("balance-director", "medium"),
|
||
("balance-seed", "medium"),
|
||
("art-director", "high"),
|
||
("art-asset-plan", "high"),
|
||
("art-polish", "medium"),
|
||
("audio-director", "low"),
|
||
("audio-asset-plan", "medium"),
|
||
("code-director", "medium"),
|
||
("code-prototype", "high"),
|
||
("quality-review", "high"),
|
||
("preview-readiness", "low"),
|
||
("preview-playtest", "low"),
|
||
("publish-strategy", "low"),
|
||
("publish-package", "medium"),
|
||
];
|
||
|
||
pub(crate) fn game_creator_llm_agent_default_reasoning_effort(
|
||
agent_id: &str,
|
||
) -> Option<&'static str> {
|
||
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
|
||
.iter()
|
||
.find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort))
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_from_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmClient, String> {
|
||
let config = build_game_creator_platform_llm_config(llm, config_path)?;
|
||
LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_without_redirects_from_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmClient, String> {
|
||
let config = build_game_creator_platform_llm_config(llm, config_path)?;
|
||
LlmClient::new_without_redirects(config)
|
||
.map_err(|error| format!("LLM client 初始化失败:{error}"))
|
||
}
|
||
|
||
fn build_game_creator_platform_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmConfig, String> {
|
||
if llm.custom_enabled {
|
||
return build_game_creator_provider_llm_config(llm, config_path);
|
||
}
|
||
if game_creator_official_llm_route_locked() {
|
||
return build_game_creator_official_platform_llm_config(llm);
|
||
}
|
||
if debug_provider_e2e_route_unlocked() {
|
||
return build_game_creator_provider_llm_config(llm, config_path);
|
||
}
|
||
#[cfg(not(test))]
|
||
{
|
||
return Err("AGC 正式运行只允许通过 API Server 使用官方 LLM Router".to_string());
|
||
}
|
||
#[cfg(test)]
|
||
build_game_creator_provider_llm_config(llm, config_path)
|
||
}
|
||
|
||
fn build_game_creator_provider_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmConfig, String> {
|
||
let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?;
|
||
let api_key =
|
||
trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?;
|
||
let base_url =
|
||
trim_config_string(&llm.base_url).ok_or_else(|| llm_base_url_config_error(config_path))?;
|
||
let model =
|
||
trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?;
|
||
validate_game_creator_llm_timing_config(llm, config_path)?;
|
||
let anthropic_strict_tool_support =
|
||
game_creator_supports_anthropic_strict_tools(api_kind, &base_url, &model);
|
||
LlmConfig::new(
|
||
LlmProvider::OpenAiCompatible,
|
||
base_url,
|
||
api_key,
|
||
model,
|
||
llm.request_timeout_ms,
|
||
llm.max_retries,
|
||
llm.retry_backoff_ms,
|
||
)
|
||
.map(|config| config.with_anthropic_strict_tool_support(anthropic_strict_tool_support))
|
||
.map_err(|error| format!("LLM 配置无效:{error}"))
|
||
}
|
||
|
||
/// Normal AGC builds never receive a Router key. Direct Rust LLM helpers
|
||
/// (for example the UI editor/resource editor paths) therefore use the same
|
||
/// authenticated API-server proxy as the Codex app-server bridge. The
|
||
/// platform access token remains in this process only; it is never serialized
|
||
/// into the AGC config or passed to child processes.
|
||
fn build_game_creator_official_platform_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<LlmConfig, String> {
|
||
let session = current_platform_session()
|
||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
|
||
let api_base_url = session.api_base_url.trim_end_matches('/');
|
||
if api_base_url.is_empty() {
|
||
return Err("登录态缺少 API Server 地址".to_string());
|
||
}
|
||
let proxy_base_url = format!("{api_base_url}/api/llm");
|
||
LlmConfig::new(
|
||
LlmProvider::OpenAiCompatible,
|
||
proxy_base_url,
|
||
session.access_token,
|
||
llm.model.clone(),
|
||
llm.request_timeout_ms,
|
||
llm.max_retries,
|
||
llm.retry_backoff_ms,
|
||
)
|
||
.map(|config| config.with_agc_client_marker(true))
|
||
.map_err(|error| format!("官方 LLM Router 代理配置无效:{error}"))
|
||
}
|
||
|
||
pub(crate) fn game_creator_supports_anthropic_strict_tools(
|
||
api_kind: LlmApiKind,
|
||
base_url: &str,
|
||
model: &str,
|
||
) -> bool {
|
||
if api_kind != LlmApiKind::Anthropic {
|
||
return false;
|
||
}
|
||
|
||
// 兼容网关即使复用了 Anthropic messages 协议,也不能据此推断 structured
|
||
// outputs 能力。只对无凭据、无自定义端口/路径的官方 HTTPS endpoint 开启。
|
||
let Ok(endpoint) = url::Url::parse(base_url) else {
|
||
return false;
|
||
};
|
||
if endpoint.scheme() != "https"
|
||
|| endpoint.host_str() != Some("api.anthropic.com")
|
||
|| endpoint.port().is_some()
|
||
|| !endpoint.username().is_empty()
|
||
|| endpoint.password().is_some()
|
||
|| endpoint.path() != "/"
|
||
|| endpoint.query().is_some()
|
||
|| endpoint.fragment().is_some()
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Claude API 的 structured outputs 从 Claude 4.5 起可用。仅识别官方 Claude
|
||
// family 的版本化 model id;不凭 `latest`、第三方别名或未知产品名猜能力。
|
||
let normalized = model.trim().to_ascii_lowercase();
|
||
let mut parts = normalized.split('-');
|
||
if parts.next() != Some("claude") || !matches!(parts.next(), Some("opus" | "sonnet" | "haiku"))
|
||
{
|
||
return false;
|
||
}
|
||
let Some(major) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
|
||
return false;
|
||
};
|
||
let minor = parts
|
||
.next()
|
||
.and_then(|value| value.parse::<u32>().ok())
|
||
.unwrap_or(0);
|
||
major > 4 || (major == 4 && minor >= 5)
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_from_config() -> Result<LlmClient, String> {
|
||
let app_config = load_game_creator_app_config()?;
|
||
build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm")
|
||
}
|
||
|
||
pub(crate) fn parse_game_creator_llm_api_kind(value: &str) -> Result<LlmApiKind, String> {
|
||
let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
|
||
let normalized = if normalized.is_empty() {
|
||
DEFAULT_GAME_CREATOR_LLM_API_KIND
|
||
} else {
|
||
normalized.as_str()
|
||
};
|
||
match normalized {
|
||
"openai_responses" => Ok(LlmApiKind::OpenAiResponses),
|
||
"openai_chat" => Ok(LlmApiKind::OpenAiChat),
|
||
"anthropic" => Ok(LlmApiKind::Anthropic),
|
||
value => Err(format!(
|
||
"LLM api_kind 无效:{value},请使用 openai_responses、openai_chat 或 anthropic"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn parse_game_creator_llm_reasoning_effort(
|
||
value: &str,
|
||
) -> Result<Option<platform_llm::LlmResponseReasoningEffort>, String> {
|
||
match value.trim().to_ascii_lowercase().as_str() {
|
||
"default" => Ok(None),
|
||
"low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)),
|
||
"medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)),
|
||
"high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)),
|
||
"max" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)),
|
||
value => Err(format!(
|
||
"LLM reasoning_effort 无效:{value},请使用 default、low、medium、high 或 max"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn apply_game_creator_llm_reasoning_effort(
|
||
request: LlmRunRequest,
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<LlmRunRequest, String> {
|
||
Ok(
|
||
match parse_game_creator_llm_reasoning_effort(&llm.reasoning_effort)? {
|
||
Some(effort) => request.with_response_reasoning_effort(effort),
|
||
None => request,
|
||
},
|
||
)
|
||
}
|
||
|
||
pub(crate) fn apply_game_creator_llm_web_search(
|
||
request: LlmRunRequest,
|
||
llm: &GameCreatorLlmConfig,
|
||
allowed: bool,
|
||
) -> Result<LlmRunRequest, String> {
|
||
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
||
if llm.web_search_enabled && api_kind == LlmApiKind::Anthropic {
|
||
return Err(
|
||
"LLM 配置不兼容:apiKind=anthropic 时 webSearchEnabled 必须为 false".to_string(),
|
||
);
|
||
}
|
||
Ok(if allowed && llm.web_search_enabled {
|
||
request.with_web_search(true)
|
||
} else {
|
||
request
|
||
})
|
||
}
|
||
|
||
fn validate_game_creator_llm_web_search_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmApiKind, String> {
|
||
let api_kind = parse_game_creator_llm_api_kind(&config.api_kind)
|
||
.map_err(|error| format!("配置项 {config_path}.apiKind 无效:{error}"))?;
|
||
if config.web_search_enabled && api_kind == LlmApiKind::Anthropic {
|
||
return Err(format!(
|
||
"配置项 {config_path}.webSearchEnabled 在 apiKind=anthropic 时必须为 false"
|
||
));
|
||
}
|
||
Ok(api_kind)
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus {
|
||
let app_config = match load_game_creator_app_config() {
|
||
Ok(config) => config,
|
||
Err(error) => {
|
||
return GameCreatorLlmConfigStatus {
|
||
agent_mode: default_game_creator_agent_mode(),
|
||
configured: false,
|
||
account_credential_state: "unavailable".to_string(),
|
||
official_route_locked: game_creator_official_llm_route_locked(),
|
||
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
|
||
stream: true,
|
||
web_search_enabled: true,
|
||
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
|
||
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
|
||
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
|
||
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
|
||
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
|
||
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||
error: Some(error),
|
||
agents: Vec::new(),
|
||
};
|
||
}
|
||
};
|
||
if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER {
|
||
return check_game_creator_codex_config(&app_config);
|
||
}
|
||
let global_route_shape_error =
|
||
validate_game_creator_llm_web_search_config(&app_config.llm, "llm").err();
|
||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||
if let Err(error) = parse_game_creator_llm_api_kind(&app_config.llm.api_kind) {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
if status.configured {
|
||
if let Err(error) = build_game_creator_llm_client_from_config() {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
}
|
||
status.agents = game_creator_llm_agent_status_definitions()
|
||
.iter()
|
||
.map(|definition| {
|
||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id);
|
||
check_game_creator_agent_llm_config_values(
|
||
&app_config.agent_mode,
|
||
&definition.agent_id,
|
||
&definition.label,
|
||
&llm,
|
||
)
|
||
})
|
||
.collect();
|
||
let mut errors = global_route_shape_error.into_iter().collect::<Vec<_>>();
|
||
let agent_errors = status
|
||
.agents
|
||
.iter()
|
||
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
|
||
.filter(|agent| !agent.configured)
|
||
.map(|agent| {
|
||
format!(
|
||
"{}:{}",
|
||
agent.label,
|
||
agent.error.as_deref().unwrap_or("配置不完整")
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
for error in agent_errors {
|
||
if !errors.contains(&error) {
|
||
errors.push(error);
|
||
}
|
||
}
|
||
status.configured = errors.is_empty();
|
||
status.error = if errors.is_empty() {
|
||
None
|
||
} else {
|
||
Some(errors.join(";"))
|
||
};
|
||
status
|
||
}
|
||
|
||
fn check_game_creator_codex_config(
|
||
app_config: &GameCreatorAppConfig,
|
||
) -> GameCreatorLlmConfigStatus {
|
||
let cli_error = check_game_creator_codex_cli_available()
|
||
.and_then(|()| {
|
||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
check_game_creator_codex_app_server_available()
|
||
} else {
|
||
Ok(())
|
||
}
|
||
})
|
||
.err();
|
||
let global_route_error = game_creator_codex_app_server_llm_route_error(
|
||
&app_config.agent_mode,
|
||
&app_config.llm,
|
||
"llm",
|
||
);
|
||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||
status.agent_mode = app_config.agent_mode.clone();
|
||
if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() {
|
||
let account_ready = current_platform_session().is_some();
|
||
status.account_credential_state = if account_ready {
|
||
"ready".to_string()
|
||
} else {
|
||
"login_required".to_string()
|
||
};
|
||
status.official_route_locked = true;
|
||
// The status endpoint is safe metadata only. Never echo a legacy
|
||
// user-supplied URL/model that was present before the official route
|
||
// migration; expose the immutable route instead.
|
||
status.configured = cli_error.is_none() && global_route_error.is_none() && account_ready;
|
||
status.error = cli_error.clone().or(global_route_error).or_else(|| {
|
||
(!account_ready).then(|| "authentication-required: 请先登录陶泥儿账号".to_string())
|
||
});
|
||
} else {
|
||
status.configured = cli_error.is_none() && global_route_error.is_none();
|
||
status.error = cli_error.clone().or(global_route_error);
|
||
}
|
||
status.agents = game_creator_llm_agent_status_definitions()
|
||
.iter()
|
||
.map(|definition| {
|
||
let llm = resolve_game_creator_llm_config_for_agent(app_config, &definition.agent_id);
|
||
let mut agent = check_game_creator_agent_llm_config_values(
|
||
&app_config.agent_mode,
|
||
&definition.agent_id,
|
||
&definition.label,
|
||
&llm,
|
||
);
|
||
let route_error = game_creator_codex_app_server_llm_route_error(
|
||
&app_config.agent_mode,
|
||
&llm,
|
||
&format!("agentLlm.{}", definition.agent_id),
|
||
);
|
||
agent.configured = cli_error.is_none() && route_error.is_none();
|
||
agent.error = cli_error.clone().or(route_error);
|
||
if !llm.custom_enabled && game_creator_official_llm_route_locked() {
|
||
agent.account_credential_state = status.account_credential_state.clone();
|
||
agent.official_route_locked = true;
|
||
agent.configured = status.configured;
|
||
agent.error = status.error.clone();
|
||
}
|
||
agent
|
||
})
|
||
.collect();
|
||
let required_errors = status
|
||
.agents
|
||
.iter()
|
||
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
|
||
.filter_map(|agent| {
|
||
agent
|
||
.error
|
||
.as_ref()
|
||
.map(|error| format!("{}:{error}", agent.label))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if !required_errors.is_empty() {
|
||
status.configured = false;
|
||
let mut errors = status.error.take().into_iter().collect::<Vec<_>>();
|
||
errors.extend(required_errors);
|
||
errors.dedup();
|
||
status.error = Some(errors.join(";"));
|
||
}
|
||
status
|
||
}
|
||
|
||
pub(crate) fn game_creator_codex_app_server_llm_route_error(
|
||
agent_mode: &str,
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Option<String> {
|
||
if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
return None;
|
||
}
|
||
if llm.custom_enabled {
|
||
if let Err(error) = validate_custom_llm_connection(llm) {
|
||
return Some(error);
|
||
}
|
||
if !llm.visible_models.contains(&llm.model) {
|
||
return Some("请至少勾选一个模型,并从已勾选列表选择模型".to_string());
|
||
}
|
||
}
|
||
if llm.api_kind != "openai_responses" {
|
||
return Some(format!(
|
||
"配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式",
|
||
llm.api_kind
|
||
));
|
||
}
|
||
None
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
|
||
crate::agent::game_creator_codex_cli_version_identity().map(|_| ())
|
||
}
|
||
|
||
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||
let executable = crate::agent::game_creator_codex_cli_executable_path()?;
|
||
let mut command = crate::new_windows_background_std_command(executable);
|
||
let output = command
|
||
.args(["app-server", "--help"])
|
||
.stdin(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null())
|
||
.output()
|
||
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
|
||
if !output.status.success()
|
||
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
|
||
{
|
||
return Err("当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_llm_config_values(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> GameCreatorLlmConfigStatus {
|
||
let api_key = trim_config_string(&config.api_key);
|
||
let base_url = trim_config_string(&config.base_url);
|
||
let model = trim_config_string(&config.model);
|
||
let api_kind = validate_game_creator_llm_web_search_config(config, config_path);
|
||
let error = api_kind.as_ref().err().cloned().or_else(|| {
|
||
match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) {
|
||
(None, _, _) => Some(llm_api_key_config_error(config_path)),
|
||
(_, None, _) => Some(llm_base_url_config_error(config_path)),
|
||
(_, _, None) => Some(llm_model_config_error(config_path)),
|
||
(Some(api_key), Some(base_url), Some(model)) => {
|
||
validate_game_creator_llm_timing_config(config, config_path)
|
||
.err()
|
||
.or_else(|| {
|
||
LlmConfig::new(
|
||
LlmProvider::OpenAiCompatible,
|
||
base_url.to_string(),
|
||
api_key.to_string(),
|
||
model.to_string(),
|
||
config.request_timeout_ms,
|
||
config.max_retries,
|
||
config.retry_backoff_ms,
|
||
)
|
||
.and_then(LlmClient::new)
|
||
.err()
|
||
.map(|error| format!("LLM 配置无效:{error}"))
|
||
})
|
||
}
|
||
}
|
||
});
|
||
|
||
GameCreatorLlmConfigStatus {
|
||
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
||
configured: error.is_none(),
|
||
account_credential_state: if error.is_none() {
|
||
"not_required"
|
||
} else {
|
||
"unavailable"
|
||
}
|
||
.to_string(),
|
||
official_route_locked: !config.custom_enabled && game_creator_official_llm_route_locked(),
|
||
reasoning_effort: config.reasoning_effort.clone(),
|
||
stream: config.stream,
|
||
web_search_enabled: config.web_search_enabled,
|
||
context_window_tokens: config.context_window_tokens,
|
||
auto_compact_token_limit: config.auto_compact_token_limit,
|
||
tool_output_token_limit: config.tool_output_token_limit,
|
||
request_timeout_ms: config.request_timeout_ms,
|
||
max_retries: config.max_retries,
|
||
retry_backoff_ms: config.retry_backoff_ms,
|
||
error,
|
||
agents: Vec::new(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_agent_llm_config_values(
|
||
agent_mode: &str,
|
||
agent_id: &str,
|
||
label: &str,
|
||
config: &GameCreatorLlmConfig,
|
||
) -> GameCreatorAgentLlmConfigStatus {
|
||
let config_path = format!("agentLlm.{agent_id}");
|
||
let mut status = check_game_creator_llm_config_values(config, &config_path);
|
||
if let Err(error) = parse_game_creator_llm_api_kind(&config.api_kind) {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
if status.configured {
|
||
if let Err(error) = build_game_creator_llm_client_from_llm_config(config, &config_path) {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
}
|
||
GameCreatorAgentLlmConfigStatus {
|
||
agent_mode: agent_mode.to_string(),
|
||
agent_id: agent_id.to_string(),
|
||
label: label.to_string(),
|
||
configured: status.configured,
|
||
account_credential_state: status.account_credential_state.clone(),
|
||
official_route_locked: status.official_route_locked,
|
||
reasoning_effort: config.reasoning_effort.clone(),
|
||
stream: config.stream,
|
||
web_search_enabled: config.web_search_enabled,
|
||
context_window_tokens: config.context_window_tokens,
|
||
auto_compact_token_limit: config.auto_compact_token_limit,
|
||
tool_output_token_limit: config.tool_output_token_limit,
|
||
request_timeout_ms: config.request_timeout_ms,
|
||
max_retries: config.max_retries,
|
||
retry_backoff_ms: config.retry_backoff_ms,
|
||
error: status.error,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_llm_timing_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<(), String> {
|
||
if config.request_timeout_ms < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS {
|
||
return Err(format!(
|
||
"配置项 {config_path}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
|
||
));
|
||
}
|
||
if config.retry_backoff_ms == 0 {
|
||
return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0"));
|
||
}
|
||
parse_game_creator_llm_reasoning_effort(&config.reasoning_effort)
|
||
.map_err(|error| format!("配置项 {config_path}.reasoningEffort 无效:{error}"))?;
|
||
validate_game_creator_llm_context_config(config, config_path)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_llm_context_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<(), String> {
|
||
const CONTEXT_SAFETY_MARGIN_TOKENS: u64 = 4_096;
|
||
if config.context_window_tokens == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.contextWindowTokens 必须大于 0"
|
||
));
|
||
}
|
||
if config.auto_compact_token_limit == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.autoCompactTokenLimit 必须大于 0"
|
||
));
|
||
}
|
||
if config.tool_output_token_limit == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.toolOutputTokenLimit 必须大于 0"
|
||
));
|
||
}
|
||
if config
|
||
.auto_compact_token_limit
|
||
.saturating_add(CONTEXT_SAFETY_MARGIN_TOKENS)
|
||
>= config.context_window_tokens
|
||
{
|
||
return Err(format!(
|
||
"配置项 {config_path}.autoCompactTokenLimit 必须至少为 contextWindowTokens 预留 {CONTEXT_SAFETY_MARGIN_TOKENS} tokens"
|
||
));
|
||
}
|
||
if config.tool_output_token_limit > config.auto_compact_token_limit {
|
||
return Err(format!(
|
||
"配置项 {config_path}.toolOutputTokenLimit 不能大于 autoCompactTokenLimit"
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String {
|
||
match api_kind {
|
||
LlmApiKind::OpenAiChat => "openai_chat",
|
||
LlmApiKind::OpenAiResponses => "openai_responses",
|
||
LlmApiKind::Anthropic => "anthropic",
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
pub(crate) fn game_creator_llm_reasoning_effort_name(
|
||
value: &str,
|
||
config_path: &str,
|
||
) -> Result<String, String> {
|
||
let normalized = value.trim().to_ascii_lowercase();
|
||
parse_game_creator_llm_reasoning_effort(&normalized)
|
||
.map_err(|error| format!("配置项 {config_path} 无效:{error}"))?;
|
||
Ok(normalized)
|
||
}
|
||
|
||
fn validate_game_creator_runtime_config_dir_metadata(
|
||
path: &Path,
|
||
tighten: bool,
|
||
initialize_windows_owner: bool,
|
||
) -> Result<(), String> {
|
||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
let _ = initialize_windows_owner;
|
||
|
||
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
||
let effective_user_id = unsafe { libc::geteuid() };
|
||
if metadata.uid() != effective_user_id {
|
||
return Err("客户端 AppData 配置目录不属于当前用户".to_string());
|
||
}
|
||
if tighten {
|
||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||
format!(
|
||
"收紧客户端 AppData 配置目录权限失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
}
|
||
let verified = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"复核客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
let mode = verified.permissions().mode() & 0o777;
|
||
if verified.uid() != effective_user_id || mode != 0o700 {
|
||
return Err(format!(
|
||
"客户端 AppData 配置目录必须由当前用户持有且权限为 0700,当前权限为 {mode:04o}"
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
||
}
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
true,
|
||
tighten,
|
||
initialize_windows_owner,
|
||
)?;
|
||
}
|
||
|
||
#[cfg(not(any(unix, windows)))]
|
||
{
|
||
let _ = tighten;
|
||
return Err("当前平台无法安全验证客户端 AppData 配置目录权限与 owner".to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Checks every already-existing path component without following links.
|
||
/// `canonicalize` alone is insufficient here because it resolves a junction
|
||
/// before the caller gets a chance to apply the owner/DACL policy.
|
||
pub(crate) fn validate_game_creator_private_path_ancestors(
|
||
path: &Path,
|
||
label: &str,
|
||
) -> Result<(), String> {
|
||
if !path.is_absolute() {
|
||
return Err(format!("{label}必须是绝对路径"));
|
||
}
|
||
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
|
||
match fs::symlink_metadata(ancestor) {
|
||
Ok(metadata) => {
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!(
|
||
"{label} 路径不能包含符号链接:{}",
|
||
ancestor.display()
|
||
));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"{label} 路径不能包含 Windows reparse point:{}",
|
||
ancestor.display()
|
||
));
|
||
}
|
||
}
|
||
if ancestor != path && !metadata.is_dir() {
|
||
return Err(format!(
|
||
"{label} 父路径必须是普通目录:{}",
|
||
ancestor.display()
|
||
));
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 {label} 路径元数据失败:{}: {error}",
|
||
ancestor.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Automatic ACL repair for managed paths is limited to objects AGC owns. A
|
||
/// separate, explicit user-selected scope below covers native picker/project
|
||
/// root results, including projects stored outside the current profile.
|
||
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||
let path = normalize_windows_policy_path(path);
|
||
if !path.is_absolute()
|
||
|| path
|
||
.components()
|
||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
let starts_with_path = |root: &Path| {
|
||
let root = normalize_windows_policy_path(root);
|
||
path == root || path.starts_with(root)
|
||
};
|
||
if game_creator_runtime_config_dir()
|
||
.as_deref()
|
||
.is_some_and(starts_with_path)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if let Some(home) = std::env::var_os("USERPROFILE")
|
||
.or_else(|| std::env::var_os("HOME"))
|
||
.map(PathBuf::from)
|
||
.filter(|candidate| candidate.is_absolute())
|
||
{
|
||
let credentials_root = home.join(".config").join("genarrative");
|
||
if starts_with_path(&credentials_root) {
|
||
return true;
|
||
}
|
||
|
||
// The Tauri release uses this stable per-user AppData directory. The
|
||
// elevated helper runs in a fresh process, so the in-memory runtime
|
||
// config-dir override is unavailable there; recognize the packaged
|
||
// path from the user's profile as well.
|
||
let packaged_app_data = home
|
||
.join("AppData")
|
||
.join("Local")
|
||
.join("world.genarrative.ai-game-creator");
|
||
if starts_with_path(&packaged_app_data) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
|
||
if let Some(root) = std::env::var_os(environment_name)
|
||
.map(PathBuf::from)
|
||
.filter(|candidate| candidate.is_absolute())
|
||
{
|
||
if starts_with_path(&root.join("world.genarrative.ai-game-creator")) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// A bare `.agent` component is not enough to authorize ownership repair:
|
||
// an arbitrary user-selected path can contain a directory with that name.
|
||
// An existing AGC marker establishes the managed project root, after
|
||
// which every regular descendant (for example `game/index.html`) is
|
||
// covered by the same repair boundary. New projects use the explicit
|
||
// project-root preparation entry below until their marker is written.
|
||
// Nested or unrelated `.agent` directories remain outside the boundary.
|
||
let agent_components = path
|
||
.components()
|
||
.filter_map(|component| match component {
|
||
std::path::Component::Normal(name)
|
||
if name.to_string_lossy().eq_ignore_ascii_case(".agent") =>
|
||
{
|
||
Some(name.to_os_string())
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if agent_components.len() > 1 {
|
||
return false;
|
||
}
|
||
|
||
for project_root in path.ancestors() {
|
||
let root_metadata = match fs::symlink_metadata(project_root) {
|
||
Ok(metadata) => metadata,
|
||
Err(_) => continue,
|
||
};
|
||
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
||
continue;
|
||
}
|
||
let agent_directory = project_root.join(".agent");
|
||
let agent_metadata = match fs::symlink_metadata(&agent_directory) {
|
||
Ok(metadata) => metadata,
|
||
Err(_) => continue,
|
||
};
|
||
let manifest_path = agent_directory.join("manifest.json");
|
||
let manifest_metadata = match fs::symlink_metadata(&manifest_path) {
|
||
Ok(metadata) => metadata,
|
||
Err(_) => continue,
|
||
};
|
||
if agent_metadata.file_type().is_symlink()
|
||
|| !agent_metadata.is_dir()
|
||
|| manifest_metadata.file_type().is_symlink()
|
||
|| !manifest_metadata.is_file()
|
||
{
|
||
continue;
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if root_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
|| agent_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
|| manifest_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(agent_name) = agent_components.first() {
|
||
let Some(actual_agent_directory) = path.ancestors().find(|candidate| {
|
||
candidate
|
||
.file_name()
|
||
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(".agent"))
|
||
}) else {
|
||
continue;
|
||
};
|
||
if actual_agent_directory != &agent_directory
|
||
|| actual_agent_directory
|
||
.file_name()
|
||
.map(|name| name != agent_name)
|
||
.unwrap_or(true)
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// A file-picker/project-root result is an explicit native user action. Once
|
||
/// the caller has crossed that boundary, a regular object may be repaired by
|
||
/// the one-shot UAC helper even when it lives outside the current profile
|
||
/// (projects are commonly stored on another drive). The normal
|
||
/// reparse/regular-object/ancestor-type checks still run before this predicate
|
||
/// is consulted, so links, junctions and non-regular objects never become
|
||
/// elevation targets.
|
||
#[cfg(windows)]
|
||
fn game_creator_user_selected_path_allows_auto_elevation(path: &Path) -> bool {
|
||
if !path.is_absolute()
|
||
|| path
|
||
.components()
|
||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||
{
|
||
return false;
|
||
}
|
||
// Never treat a drive/UNC root as a user file. This also prevents an
|
||
// unreadable ancestor walk from turning a picker selection into ownership
|
||
// repair of the entire volume root. A native picker can technically
|
||
// return Windows/Program Files paths, but taking ownership there would
|
||
// damage the operating system rather than repair an AGC user file.
|
||
if path.as_os_str().is_empty() || path.file_name().is_none() {
|
||
return false;
|
||
}
|
||
let normalized = path
|
||
.to_string_lossy()
|
||
.replace('/', "\\")
|
||
.trim_end_matches('\\')
|
||
.to_ascii_lowercase();
|
||
for variable in [
|
||
"WINDIR",
|
||
"PROGRAMFILES",
|
||
"PROGRAMFILES(X86)",
|
||
"PROGRAMDATA",
|
||
"COMMONPROGRAMFILES",
|
||
"COMMONPROGRAMFILES(X86)",
|
||
] {
|
||
let Some(root) = std::env::var_os(variable).map(PathBuf::from) else {
|
||
continue;
|
||
};
|
||
let root = root
|
||
.to_string_lossy()
|
||
.replace('/', "\\")
|
||
.trim_end_matches('\\')
|
||
.to_ascii_lowercase();
|
||
if normalized == root || normalized.starts_with(&(root + "\\")) {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub(crate) enum WindowsAclRepairScope {
|
||
Managed,
|
||
UserSelected,
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
impl WindowsAclRepairScope {
|
||
fn wire_name(self) -> &'static str {
|
||
match self {
|
||
Self::Managed => "managed",
|
||
Self::UserSelected => "user-selected",
|
||
}
|
||
}
|
||
|
||
fn allows_path(self, path: &Path) -> bool {
|
||
match self {
|
||
Self::Managed => game_creator_private_path_allows_auto_elevation(path),
|
||
Self::UserSelected => game_creator_user_selected_path_allows_auto_elevation(path),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result<WindowsAclRepairScope, String> {
|
||
match value.trim() {
|
||
"managed" => Ok(WindowsAclRepairScope::Managed),
|
||
"user-selected" => Ok(WindowsAclRepairScope::UserSelected),
|
||
_ => Err("AGC ACL 修复 scope 无效".to_string()),
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope {
|
||
let path = normalize_windows_policy_path(path);
|
||
let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root);
|
||
if let Some(home) = std::env::var_os("USERPROFILE")
|
||
.or_else(|| std::env::var_os("HOME"))
|
||
.map(PathBuf::from)
|
||
.filter(|candidate| candidate.is_absolute())
|
||
{
|
||
if is_builtin_root(home.join(".config").join("genarrative"))
|
||
|| is_builtin_root(
|
||
home.join("AppData")
|
||
.join("Local")
|
||
.join("world.genarrative.ai-game-creator"),
|
||
)
|
||
{
|
||
return WindowsAclRepairScope::Managed;
|
||
}
|
||
}
|
||
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
|
||
if let Some(root) = std::env::var_os(environment_name)
|
||
.map(PathBuf::from)
|
||
.filter(|candidate| candidate.is_absolute())
|
||
{
|
||
if is_builtin_root(root.join("world.genarrative.ai-game-creator")) {
|
||
return WindowsAclRepairScope::Managed;
|
||
}
|
||
}
|
||
}
|
||
WindowsAclRepairScope::UserSelected
|
||
}
|
||
|
||
/// Prepares a caller-selected AGC project root. The root itself has no
|
||
/// `.agent/manifest.json` yet during first initialization, so it cannot use
|
||
/// the marker-based auto-elevation predicate above. This explicit entry is
|
||
/// only called by project initialization and the Runner, where the path has
|
||
/// already been accepted as the workspace root; descendants remain subject
|
||
/// to the marker-based managed-root check.
|
||
pub(crate) fn prepare_game_creator_project_root_for_read(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
label: &str,
|
||
) -> Result<bool, String> {
|
||
let metadata = match fs::symlink_metadata(path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||
Err(error) => {
|
||
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
|
||
#[cfg(windows)]
|
||
{
|
||
// A selected project root may be unreadable before the leaf
|
||
// metadata can be inspected. Keep the same explicit
|
||
// user-selected scope and one-shot UAC path used after the
|
||
// metadata check, while rejecting all paths outside the
|
||
// current profile.
|
||
let scope = if game_creator_private_path_allows_auto_elevation(path) {
|
||
WindowsAclRepairScope::Managed
|
||
} else if user_selected_path_is_authorized(path, is_directory)
|
||
&& game_creator_user_selected_path_allows_auto_elevation(path)
|
||
{
|
||
WindowsAclRepairScope::UserSelected
|
||
} else {
|
||
return Err(detail);
|
||
};
|
||
if windows_acl_error_may_need_elevation(&detail) {
|
||
return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
scope,
|
||
)
|
||
.map(|_| true)
|
||
.map_err(|repair_error| {
|
||
format!("{detail};自动提权修复未完成:{repair_error}")
|
||
});
|
||
}
|
||
}
|
||
return Err(detail);
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink()
|
||
|| (is_directory && !metadata.is_dir())
|
||
|| (!is_directory && !metadata.is_file())
|
||
{
|
||
return Err(format!(
|
||
"{label} 必须是普通{},不能是链接或其他对象:{}",
|
||
if is_directory { "目录" } else { "文件" },
|
||
path.display()
|
||
));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"{label} 不能是 Windows reparse point:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// A project root is explicitly selected by the user before the AGC
|
||
// marker exists, so the managed-path predicate cannot identify it yet.
|
||
// Use the explicit user-selected scope for the root; this allows a
|
||
// project stored on another drive to be repaired while retaining the
|
||
// strict reparse/type checks above.
|
||
let scope = if game_creator_private_path_allows_auto_elevation(path) {
|
||
WindowsAclRepairScope::Managed
|
||
} else if user_selected_path_is_authorized(path, is_directory)
|
||
&& game_creator_user_selected_path_allows_auto_elevation(path)
|
||
{
|
||
WindowsAclRepairScope::UserSelected
|
||
} else {
|
||
#[cfg(all(windows, test))]
|
||
if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
|
||
// 测试夹具:在提权 shell 里,系统临时目录下新建的目录默认所有者是
|
||
// Administrators 组而不是当前 TokenUser,测试进程无法提权改所有者。
|
||
// 该目录由当前测试进程创建,因此按“本调用创建的对象”初始化所有者后
|
||
// 重试;其它越权所有者、以及临时目录之外的路径仍然失败关闭。
|
||
if windows_path_is_under_test_temp_dir(path) {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
true,
|
||
)?;
|
||
return Ok(true);
|
||
}
|
||
}
|
||
return secure_windows_game_creator_path_for_current_user(path, is_directory, true)
|
||
.map(|_| true);
|
||
};
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
scope,
|
||
)?;
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
validate_game_creator_private_path_ancestors(path, label)?;
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn validate_game_creator_private_path_ancestors_with_auto_elevation(
|
||
path: &Path,
|
||
label: &str,
|
||
) -> Result<(), String> {
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
|
||
path,
|
||
label,
|
||
WindowsAclRepairScope::Managed,
|
||
)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
|
||
path: &Path,
|
||
label: &str,
|
||
scope: WindowsAclRepairScope,
|
||
) -> Result<(), String> {
|
||
#[cfg(test)]
|
||
{
|
||
return validate_game_creator_private_path_ancestors(path, label);
|
||
}
|
||
#[cfg(not(test))]
|
||
{
|
||
let target_user_sid = current_windows_token_user_sid_string()?;
|
||
let mut attempted_targets = Vec::<PathBuf>::new();
|
||
loop {
|
||
match validate_game_creator_private_path_ancestors(path, label) {
|
||
Ok(()) => return Ok(()),
|
||
Err(error)
|
||
if scope.allows_path(path) && windows_acl_error_may_need_elevation(&error) =>
|
||
{
|
||
let repair_target = windows_acl_repair_target(path, scope);
|
||
if attempted_targets
|
||
.iter()
|
||
.any(|target| target == &repair_target)
|
||
{
|
||
return Err(format!(
|
||
"{error};自动提权修复重复命中同一目标,拒绝继续重试:{}",
|
||
repair_target.display()
|
||
));
|
||
}
|
||
attempted_targets.push(repair_target);
|
||
attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err(
|
||
|repair_error| format!("{error};自动提权修复未完成:{repair_error}"),
|
||
)?;
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Creates a private directory tree one component at a time. `create_dir_all`
|
||
/// can follow a junction that appears between components, so every existing
|
||
/// and newly-created component is checked before the next one is touched.
|
||
pub(crate) fn ensure_game_creator_private_directory_tree(
|
||
path: &Path,
|
||
label: &str,
|
||
) -> Result<bool, String> {
|
||
#[cfg(windows)]
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?;
|
||
#[cfg(not(windows))]
|
||
validate_game_creator_private_path_ancestors(path, label)?;
|
||
let mut missing = Vec::new();
|
||
let mut current = path.to_path_buf();
|
||
loop {
|
||
match fs::symlink_metadata(¤t) {
|
||
Ok(metadata) => {
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!("{label} 不能包含符号链接:{}", current.display()));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"{label} 不能包含 Windows reparse point:{}",
|
||
current.display()
|
||
));
|
||
}
|
||
}
|
||
if !metadata.is_dir() {
|
||
return Err(format!(
|
||
"{label} 父路径必须是普通目录:{}",
|
||
current.display()
|
||
));
|
||
}
|
||
break;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
missing.push(current.clone());
|
||
current = current
|
||
.parent()
|
||
.ok_or_else(|| {
|
||
format!("{label} 没有可用于创建的现存父目录:{}", path.display())
|
||
})?
|
||
.to_path_buf();
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 {label} 元数据失败:{}: {error}",
|
||
current.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
let created_target = missing.first().is_some_and(|created| created == path);
|
||
for directory in missing.into_iter().rev() {
|
||
let create_result = fs::create_dir(&directory);
|
||
match create_result {
|
||
Ok(()) => {
|
||
#[cfg(all(windows, test))]
|
||
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
||
#[cfg(windows)]
|
||
// This invocation created the directory: initialize it in
|
||
// process first, with a narrowly-scoped managed-path fallback
|
||
// only if Windows rejects that local ACL update.
|
||
harden_new_game_creator_private_path(&directory, true, label)?;
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).map_err(
|
||
|error| format!("收紧 {label} 权限失败:{}: {error}", directory.display()),
|
||
)?;
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||
// Another process won the create race. This object was not
|
||
// created by the current invocation, so it must go through
|
||
// the full existing-object owner/DACL gate before we touch
|
||
// any descendant. A type-only metadata check here would allow
|
||
// an attacker-created directory to become trusted.
|
||
prepare_game_creator_private_path_for_read(&directory, true, label)?;
|
||
}
|
||
#[cfg(windows)]
|
||
Err(error)
|
||
if matches!(
|
||
error.kind(),
|
||
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
|
||
) || matches!(error.raw_os_error(), Some(5)) =>
|
||
{
|
||
let parent = directory.parent().ok_or_else(|| {
|
||
format!("创建 {label} 失败:{}: {error}", directory.display())
|
||
})?;
|
||
if game_creator_private_path_allows_auto_elevation(parent) {
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
parent, true, true,
|
||
)?;
|
||
} else {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
parent, true, true, true,
|
||
)?;
|
||
}
|
||
fs::create_dir(&directory).map_err(|retry_error| {
|
||
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
||
})?;
|
||
harden_new_game_creator_private_path(&directory, true, label)?;
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"创建 {label} 失败:{}: {error}",
|
||
directory.display()
|
||
));
|
||
}
|
||
}
|
||
let metadata = fs::symlink_metadata(&directory)
|
||
.map_err(|error| format!("复核 {label} 失败:{}: {error}", directory.display()))?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err(format!("{label} 必须是普通目录:{}", directory.display()));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"{label} 不能是 Windows reparse point:{}",
|
||
directory.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(created_target)
|
||
}
|
||
|
||
/// Hardens a directory or file that this invocation has just created. This
|
||
/// intentionally does not adopt an existing object: callers that discover an
|
||
/// existing path must go through `prepare_game_creator_private_path_for_read`,
|
||
/// which performs the strict owner/reparse/DACL checks and the controlled UAC
|
||
/// repair flow. Keeping the two paths separate prevents a race from turning
|
||
/// an attacker-owned object into an AGC-managed credential or sidecar file.
|
||
pub(crate) fn harden_new_game_creator_private_path(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
label: &str,
|
||
) -> Result<(), String> {
|
||
if !path.is_absolute() {
|
||
return Err(format!("{label}必须是绝对路径"));
|
||
}
|
||
let metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("读取新建 {label} 元数据失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink()
|
||
|| (is_directory && !metadata.is_dir())
|
||
|| (!is_directory && !metadata.is_file())
|
||
{
|
||
return Err(format!(
|
||
"新建 {label} 必须是普通{},不能是链接或其他对象:{}",
|
||
if is_directory { "目录" } else { "文件" },
|
||
path.display()
|
||
));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"新建 {label} 不能是 Windows reparse point:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// This invocation created the object, so local hardening is always
|
||
// the first path. Some Windows configurations can nevertheless
|
||
// reject the descriptor update (for example when an inherited ACL is
|
||
// protected by the parent). Only a managed path may use the existing
|
||
// one-shot repair in that exceptional case; ordinary new projects do
|
||
// not prompt for UAC.
|
||
if let Err(local_error) =
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
true,
|
||
)
|
||
{
|
||
if !game_creator_private_path_allows_auto_elevation(path)
|
||
|| !windows_acl_error_may_need_elevation(&local_error)
|
||
{
|
||
return Err(local_error);
|
||
}
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
)
|
||
.map_err(|repair_error| {
|
||
format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}")
|
||
})?;
|
||
}
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
fs::set_permissions(
|
||
path,
|
||
fs::Permissions::from_mode(if is_directory { 0o700 } else { 0o600 }),
|
||
)
|
||
.map_err(|error| format!("收紧新建 {label} 权限失败:{}: {error}", path.display()))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Writes a regular AGC-managed file only after its parent and any existing
|
||
/// target have passed the private-path policy. The post-write check also
|
||
/// hardens a newly-created file, so inherited Windows ACLs cannot remain on a
|
||
/// file that was created by this process.
|
||
pub(crate) fn write_game_creator_private_file(
|
||
path: &Path,
|
||
bytes: &[u8],
|
||
label: &str,
|
||
) -> Result<(), String> {
|
||
if !path.is_absolute() {
|
||
return Err(format!("{label}必须是绝对路径"));
|
||
}
|
||
let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?;
|
||
ensure_game_creator_private_directory_tree(parent, label)?;
|
||
prepare_game_creator_private_path_for_read(parent, true, label)?;
|
||
let existed = prepare_game_creator_private_path_for_read(path, false, label)?;
|
||
|
||
// Never write directly through the checked pathname. A pathname can be
|
||
// replaced after the preflight by another process (or by a junction/link
|
||
// attack). Create and harden a sibling temporary inode first, then link
|
||
// it into place without replacement. Existing targets are moved to a
|
||
// unique backup only after a second strict check; if installation fails,
|
||
// the original target is restored.
|
||
let file_name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.ok_or_else(|| format!("{label}文件名无效"))?;
|
||
let temporary = (0..8)
|
||
.map(|attempt| {
|
||
path.with_file_name(format!(
|
||
".{file_name}.tmp-{}-{}-{attempt}",
|
||
std::process::id(),
|
||
uuid::Uuid::new_v4().simple()
|
||
))
|
||
})
|
||
.find(|candidate| {
|
||
matches!(
|
||
fs::symlink_metadata(candidate),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||
)
|
||
})
|
||
.ok_or_else(|| format!("创建 {label} 临时文件路径失败:目录中存在冲突残留"))?;
|
||
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options.open(&temporary).map_err(|error| {
|
||
format!(
|
||
"创建 {label} 临时文件失败:{}: {error}",
|
||
temporary.display()
|
||
)
|
||
})?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&temporary, false, label) {
|
||
drop(file);
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(error);
|
||
}
|
||
let write_result = std::io::Write::write_all(&mut file, bytes).and_then(|_| file.sync_all());
|
||
drop(file);
|
||
if let Err(error) = write_result {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!(
|
||
"写入 {label} 临时文件失败:{}: {error}",
|
||
temporary.display()
|
||
));
|
||
}
|
||
let backup = path.with_file_name(format!(
|
||
".{file_name}.previous-{}-{}",
|
||
std::process::id(),
|
||
uuid::Uuid::new_v4().simple()
|
||
));
|
||
if existed {
|
||
// Re-check immediately before moving the old target. Links and
|
||
// reparse points remain fail-closed; only a regular managed file may
|
||
// enter the recoverable replacement path.
|
||
if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(error);
|
||
}
|
||
if fs::symlink_metadata(&backup).is_ok() {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!("{label}替换备份路径已存在,已拒绝覆盖"));
|
||
}
|
||
if let Err(error) = fs::rename(path, &backup) {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!("准备替换 {label} 失败:{error}"));
|
||
}
|
||
}
|
||
|
||
let install_result = fs::hard_link(&temporary, path).and_then(|_| fs::remove_file(&temporary));
|
||
if let Err(error) = install_result {
|
||
let _ = fs::remove_file(&temporary);
|
||
if existed {
|
||
let _ = fs::rename(&backup, path);
|
||
}
|
||
return Err(format!("原子安装 {label} 失败:{error}"));
|
||
}
|
||
if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) {
|
||
if existed {
|
||
let _ = fs::remove_file(path);
|
||
let _ = fs::rename(&backup, path);
|
||
} else {
|
||
let _ = fs::remove_file(path);
|
||
}
|
||
return Err(format!("复核 {label} 失败:{error}"));
|
||
}
|
||
if existed {
|
||
fs::remove_file(&backup)
|
||
.map_err(|error| format!("回收 {label} 旧文件备份失败:{error}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Appends to a regular AGC-managed file while applying the same ACL policy
|
||
/// as replacement writes. This is used for human-readable logs and memory
|
||
/// journals whose append semantics are part of their existing contract.
|
||
pub(crate) fn append_game_creator_private_file(
|
||
path: &Path,
|
||
bytes: &[u8],
|
||
label: &str,
|
||
) -> Result<(), String> {
|
||
if !path.is_absolute() {
|
||
return Err(format!("{label}必须是绝对路径"));
|
||
}
|
||
let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?;
|
||
ensure_game_creator_private_directory_tree(parent, label)?;
|
||
prepare_game_creator_private_path_for_read(parent, true, label)?;
|
||
let existed = prepare_game_creator_private_path_for_read(path, false, label)?;
|
||
if existed {
|
||
let metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("读取 {label} 元数据失败:{}: {error}", path.display()))?;
|
||
if !metadata.is_file() {
|
||
return Err(format!("{label} 必须是普通文件"));
|
||
}
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).append(true).read(true);
|
||
if !existed {
|
||
options.create_new(true);
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
const FILE_SHARE_READ: u32 = 0x0000_0001;
|
||
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
|
||
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
|
||
options
|
||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
|
||
}
|
||
let mut file = options
|
||
.open(path)
|
||
.map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?;
|
||
let opened_metadata = file.metadata().map_err(|error| {
|
||
format!(
|
||
"读取 {label} 文件句柄元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if !opened_metadata.is_file() {
|
||
drop(file);
|
||
return Err(format!("{label} 必须是普通文件"));
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::MetadataExt;
|
||
if opened_metadata.nlink() != 1 {
|
||
drop(file);
|
||
return Err(format!("{label} 不能是硬链接"));
|
||
}
|
||
let path_metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("复核 {label} 路径失败:{}: {error}", path.display()))?;
|
||
if path_metadata.file_type().is_symlink()
|
||
|| path_metadata.dev() != opened_metadata.dev()
|
||
|| path_metadata.ino() != opened_metadata.ino()
|
||
{
|
||
drop(file);
|
||
return Err(format!("{label} 路径在安全打开期间发生替换"));
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
crate::runner::validate_windows_regular_file_handle(&file, label)?;
|
||
if !existed {
|
||
if let Err(error) = harden_new_game_creator_private_path(path, false, label) {
|
||
drop(file);
|
||
let _ = fs::remove_file(path);
|
||
return Err(error);
|
||
}
|
||
}
|
||
use std::io::Write as _;
|
||
file.write_all(bytes)
|
||
.and_then(|_| file.sync_data())
|
||
.map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?;
|
||
if existed {
|
||
prepare_game_creator_private_path_for_read(path, false, label)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Checks a private file/directory before the caller opens it. Windows ACL
|
||
/// failures must be handled before `OpenOptions::open`: an inherited DACL can
|
||
/// otherwise make the open fail before the strict verifier gets a chance to
|
||
/// request UAC repair. Missing leaves are returned as `false`; existing
|
||
/// objects are fully validated and, on Windows, repaired/re-validated through
|
||
/// the normal auto-elevation path.
|
||
pub(crate) fn prepare_game_creator_private_path_for_read(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
label: &str,
|
||
) -> Result<bool, String> {
|
||
let metadata = match fs::symlink_metadata(path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||
Err(error) => {
|
||
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
|
||
#[cfg(windows)]
|
||
{
|
||
let result = if game_creator_private_path_allows_auto_elevation(path) {
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
)
|
||
} else {
|
||
verify_game_creator_private_path_or_test_temp_owner(path, is_directory)
|
||
};
|
||
return result.map(|_| true).map_err(|repair_error| {
|
||
if game_creator_private_path_allows_auto_elevation(path) {
|
||
format!("{detail};自动提权修复未完成:{repair_error}")
|
||
} else {
|
||
format!("{detail};严格私有权限校验未通过:{repair_error}")
|
||
}
|
||
});
|
||
}
|
||
#[cfg(not(windows))]
|
||
return Err(detail);
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink()
|
||
|| (is_directory && !metadata.is_dir())
|
||
|| (!is_directory && !metadata.is_file())
|
||
{
|
||
return Err(format!(
|
||
"{label} 必须是普通{},不能是链接或其他对象:{}",
|
||
if is_directory { "目录" } else { "文件" },
|
||
path.display()
|
||
));
|
||
}
|
||
// Metadata on the leaf can be readable while traversal of an ancestor is
|
||
// blocked by a foreign owner or inherited ACL. Give the managed object a
|
||
// single explicit repair opportunity before surfacing a permission error;
|
||
// links/reparse points have already been rejected above and therefore stay
|
||
// fail-closed.
|
||
#[cfg(windows)]
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?;
|
||
#[cfg(not(windows))]
|
||
validate_game_creator_private_path_ancestors(path, label)?;
|
||
#[cfg(windows)]
|
||
if game_creator_private_path_allows_auto_elevation(path) {
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
)?;
|
||
} else {
|
||
// User-selected external files are never silently adopted. Keep the
|
||
// strict owner/DACL check, but do not escalate an arbitrary path.
|
||
verify_game_creator_private_path_or_test_temp_owner(path, is_directory)?;
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// Prepares a path returned by an explicit native file picker. On Windows,
|
||
/// owner/DACL failures on a regular selected object receive the same one-shot
|
||
/// UAC repair as AGC-managed files, including projects stored outside the
|
||
/// current profile. Reparse points, links, non-regular objects and ancestor
|
||
/// type conflicts are rejected before any repair attempt.
|
||
pub(crate) fn prepare_game_creator_user_selected_path_for_read(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
label: &str,
|
||
) -> Result<bool, String> {
|
||
let metadata = match fs::symlink_metadata(path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||
Err(error) => {
|
||
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
|
||
#[cfg(windows)]
|
||
{
|
||
// Explicit native file-picker/project-root selections are
|
||
// bounded UAC targets even when the normal token cannot
|
||
// traverse far enough to read leaf metadata. The helper still
|
||
// re-validates owner, type and reparse state before changing
|
||
// anything.
|
||
if user_selected_path_is_authorized(path, is_directory)
|
||
&& game_creator_user_selected_path_allows_auto_elevation(path)
|
||
&& windows_acl_error_may_need_elevation(&detail)
|
||
{
|
||
return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
WindowsAclRepairScope::UserSelected,
|
||
)
|
||
.map(|_| true)
|
||
.map_err(|repair_error| {
|
||
format!("{detail};自动提权修复未完成:{repair_error}")
|
||
});
|
||
}
|
||
}
|
||
return Err(detail);
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink()
|
||
|| (is_directory && !metadata.is_dir())
|
||
|| (!is_directory && !metadata.is_file())
|
||
{
|
||
return Err(format!(
|
||
"{label} 必须是普通{},不能是链接或其他对象:{}",
|
||
if is_directory { "目录" } else { "文件" },
|
||
path.display()
|
||
));
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"{label} 不能是 Windows reparse point:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
if user_selected_path_is_authorized(path, is_directory)
|
||
&& game_creator_user_selected_path_allows_auto_elevation(path)
|
||
{
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
|
||
path,
|
||
label,
|
||
WindowsAclRepairScope::UserSelected,
|
||
)?;
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
WindowsAclRepairScope::UserSelected,
|
||
)?;
|
||
} else {
|
||
validate_game_creator_private_path_ancestors(path, label)?;
|
||
secure_windows_game_creator_path_for_current_user(path, is_directory, true)?;
|
||
}
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
validate_game_creator_private_path_ancestors(path, label)?;
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// Reads an AGC-managed private text file through a validated regular-file
|
||
/// handle. The pathname is used only for preflight/open identity checks; bytes
|
||
/// are read from the handle so a later rename cannot redirect the read.
|
||
pub(crate) fn read_game_creator_private_file_to_string(
|
||
path: &Path,
|
||
label: &str,
|
||
max_bytes: u64,
|
||
) -> Result<String, String> {
|
||
let (mut file, metadata) = open_project_private_regular_file(path, label)?;
|
||
if metadata.len() > max_bytes {
|
||
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
|
||
}
|
||
let mut content = String::with_capacity(metadata.len() as usize);
|
||
file.read_to_string(&mut content)
|
||
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
|
||
let final_metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
|
||
if final_metadata.len() != metadata.len() {
|
||
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
|
||
}
|
||
Ok(content)
|
||
}
|
||
|
||
fn resolve_game_creator_runtime_config_dir(
|
||
path: &Path,
|
||
create_and_tighten: bool,
|
||
) -> Result<PathBuf, String> {
|
||
if !path.is_absolute() {
|
||
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
|
||
}
|
||
#[cfg(windows)]
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
|
||
path,
|
||
"客户端 AppData 配置目录",
|
||
game_creator_runtime_config_repair_scope(path),
|
||
)?;
|
||
#[cfg(not(windows))]
|
||
validate_game_creator_private_path_ancestors(path, "客户端 AppData 配置目录")?;
|
||
let mut created = false;
|
||
if create_and_tighten {
|
||
match fs::symlink_metadata(path) {
|
||
Ok(_) => {}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
if let Some(parent) = path.parent() {
|
||
ensure_game_creator_private_directory_tree(
|
||
parent,
|
||
"客户端 AppData 配置父目录",
|
||
)?;
|
||
}
|
||
match fs::create_dir(path) {
|
||
Ok(()) => created = true,
|
||
// 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。
|
||
Err(create_error)
|
||
if create_error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||
Err(create_error) => {
|
||
return Err(format!(
|
||
"创建客户端 AppData 配置目录失败:{}: {create_error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"检查客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
// canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。
|
||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||
let canonical = match fs::canonicalize(path) {
|
||
Ok(canonical) => canonical,
|
||
Err(error) => {
|
||
let detail = format!(
|
||
"解析客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
);
|
||
#[cfg(windows)]
|
||
if !cfg!(test)
|
||
&& windows_acl_error_may_need_elevation(&detail)
|
||
&& game_creator_private_path_allows_auto_elevation(path)
|
||
{
|
||
// canonicalize follows the directory and therefore can fail
|
||
// with access denied before the normal owner/DACL verifier is
|
||
// reached. Repair the managed target first, then canonicalize
|
||
// again so a denied config directory gets the same one-shot
|
||
// UAC treatment as an already-readable object.
|
||
let target_user_sid = current_windows_token_user_sid_string()
|
||
.map_err(|sid_error| format!("{detail};读取当前用户 SID 失败:{sid_error}"))?;
|
||
attempt_elevated_windows_acl_repair(
|
||
path,
|
||
&target_user_sid,
|
||
game_creator_runtime_config_repair_scope(path),
|
||
)
|
||
.map_err(|repair_error| format!("{detail};自动提权修复未完成:{repair_error}"))?;
|
||
validate_game_creator_runtime_config_dir_metadata(path, true, false)?;
|
||
return fs::canonicalize(path).map_err(|canonicalize_error| {
|
||
format!(
|
||
"ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}",
|
||
path.display()
|
||
)
|
||
});
|
||
}
|
||
return Err(detail);
|
||
}
|
||
};
|
||
match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created)
|
||
{
|
||
Ok(()) => {}
|
||
#[cfg(windows)]
|
||
Err(error) if !cfg!(test) && windows_acl_error_may_need_elevation(&error) => {
|
||
// Any existing private AppData directory that cannot be safely
|
||
// read is repaired through the one-shot elevated helper. This
|
||
// covers inherited ACLs and foreign owners while the earlier
|
||
// entry/reparse checks remain fail-closed.
|
||
let target_user_sid = current_windows_token_user_sid_string()
|
||
.map_err(|sid_error| format!("{error};读取当前用户 SID 失败:{sid_error}"))?;
|
||
attempt_elevated_windows_acl_repair(
|
||
path,
|
||
&target_user_sid,
|
||
game_creator_runtime_config_repair_scope(path),
|
||
)
|
||
.map_err(|repair_error| format!("{error};自动提权修复未完成:{repair_error}"))?;
|
||
validate_game_creator_runtime_config_dir_metadata(path, true, false)?;
|
||
return fs::canonicalize(path).map_err(|canonicalize_error| {
|
||
format!(
|
||
"ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}",
|
||
path.display()
|
||
)
|
||
});
|
||
}
|
||
#[cfg(windows)]
|
||
Err(error)
|
||
if cfg!(test)
|
||
&& create_and_tighten
|
||
&& !created
|
||
&& error.starts_with("Windows 安全对象不属于当前用户:") =>
|
||
{
|
||
let backup = migrate_windows_foreign_owner_config_dir(path)?;
|
||
fs::create_dir(path).map_err(|create_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}",
|
||
backup.display(),
|
||
path.display()
|
||
)
|
||
})?;
|
||
validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err(
|
||
|validation_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}",
|
||
backup.display()
|
||
)
|
||
},
|
||
)?;
|
||
return fs::canonicalize(path).map_err(|canonicalize_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}",
|
||
backup.display(),
|
||
path.display()
|
||
)
|
||
});
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
Ok(canonical)
|
||
}
|
||
|
||
fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> {
|
||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||
let parent = path.parent().ok_or_else(|| {
|
||
format!(
|
||
"AppData 配置目录没有可用于安全迁移的父目录:{}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
let name = path
|
||
.file_name()
|
||
.ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?;
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis();
|
||
for attempt in 0..100_u32 {
|
||
let backup = parent.join(format!(
|
||
"{}.owner-mismatch-backup-{timestamp}-{}-{attempt}",
|
||
name.to_string_lossy(),
|
||
std::process::id()
|
||
));
|
||
match fs::symlink_metadata(&backup) {
|
||
Ok(_) => continue,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"检查旧 AppData 配置备份路径失败:{}: {error}",
|
||
backup.display()
|
||
));
|
||
}
|
||
}
|
||
// 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。
|
||
fs::rename(path, &backup).map_err(|error| {
|
||
format!(
|
||
"AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}:{error}",
|
||
path.display(),
|
||
backup.display()
|
||
)
|
||
})?;
|
||
return Ok(backup);
|
||
}
|
||
Err(format!(
|
||
"AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}",
|
||
path.display()
|
||
))
|
||
}
|
||
|
||
pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
resolve_game_creator_runtime_config_dir(path, true)
|
||
}
|
||
|
||
pub(crate) fn inspect_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
resolve_game_creator_runtime_config_dir(path, false)
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_runtime_config_dir_outside_project(
|
||
config_dir: &Path,
|
||
project_root: &Path,
|
||
) -> Result<(), String> {
|
||
if config_dir == project_root || config_dir.starts_with(project_root) {
|
||
return Err(
|
||
"--config-dir 必须是项目目录外的 AppData,不能等于项目或位于项目内".to_string(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 测试夹具专用:判断某个已存在的项目根是否只是“系统临时目录下所有者不是当前用户”。
|
||
///
|
||
/// 部分 Windows 主机(例如以提权 shell 运行测试)在 `%TEMP%` 下新建的目录,默认所有者是
|
||
/// `BUILTIN\Administrators` 组而不是当前 TokenUser;测试进程无法提权改所有者,于是严格
|
||
/// 校验会拒绝一个由测试自己创建、且确实位于系统临时目录的目录。只有测试构建、路径位于
|
||
/// 系统临时目录、并且失败原因确实是所有者不匹配时才返回 true;临时目录之外的越权所有者
|
||
/// 继续失败关闭。
|
||
#[cfg(all(windows, test))]
|
||
fn windows_test_temp_path_needs_owner_initialization(path: &Path, is_directory: bool) -> bool {
|
||
if !path.is_absolute() {
|
||
return false;
|
||
}
|
||
match secure_windows_game_creator_path_for_current_user(path, is_directory, true) {
|
||
Ok(()) => false,
|
||
Err(error) => {
|
||
error.contains("安全对象不属于当前用户") && windows_path_is_under_test_temp_dir(path)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 严格校验一个既有私有对象;测试构建下对系统临时目录内的所有者偏差做一次性所有者
|
||
/// 初始化重试,其余情况保持严格失败关闭。
|
||
#[cfg(windows)]
|
||
fn verify_game_creator_private_path_or_test_temp_owner(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
) -> Result<(), String> {
|
||
#[cfg(test)]
|
||
if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
|
||
return secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
true,
|
||
);
|
||
}
|
||
secure_windows_game_creator_path_for_current_user(path, is_directory, true)
|
||
}
|
||
|
||
#[cfg(all(windows, test))]
|
||
fn windows_path_is_under_test_temp_dir(path: &Path) -> bool {
|
||
let normalize = |value: &Path| {
|
||
value
|
||
.to_string_lossy()
|
||
.replace('/', "\\")
|
||
.trim_end_matches('\\')
|
||
.to_ascii_lowercase()
|
||
};
|
||
let temp_dir = std::env::temp_dir();
|
||
let mut roots = vec![normalize(&temp_dir)];
|
||
if let Ok(canonical) = temp_dir.canonicalize() {
|
||
let root = normalize(&canonical);
|
||
if !roots.contains(&root) {
|
||
roots.push(root);
|
||
}
|
||
}
|
||
let candidate = normalize(path);
|
||
roots
|
||
.iter()
|
||
.any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\")))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
tighten,
|
||
false,
|
||
None,
|
||
)
|
||
}
|
||
|
||
/// Strictly validates a Windows private object and, when its owner/DACL cannot
|
||
/// be used by the current account, performs one explicit UAC repair before
|
||
/// validating again. Reparse points and non-regular objects remain
|
||
/// fail-closed; a repaired regular object is reassigned to the current token
|
||
/// user and receives a private non-inherited DACL.
|
||
#[cfg(windows)]
|
||
pub(crate) fn secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path,
|
||
is_directory,
|
||
tighten,
|
||
WindowsAclRepairScope::Managed,
|
||
)
|
||
}
|
||
|
||
/// Same strict verifier as the managed-path entry, but with an explicit scope
|
||
/// selected by the caller. This is used by file-picker imports only; the
|
||
/// scope is included in the one-shot UAC ticket and checked again by the
|
||
/// elevated child process.
|
||
#[cfg(windows)]
|
||
fn secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
scope: WindowsAclRepairScope,
|
||
) -> Result<(), String> {
|
||
#[cfg(test)]
|
||
{
|
||
// Unit tests must not trigger an interactive UAC prompt. The strict
|
||
// verifier remains directly testable. For an owner-correct object
|
||
// whose only defect is an inherited DACL, emulate the formal prepare
|
||
// entry's local tightening in-process; foreign-owner fixtures still
|
||
// fail closed because they cannot be reassigned without elevation.
|
||
return match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten)
|
||
{
|
||
Ok(()) => Ok(()),
|
||
Err(error)
|
||
if scope.allows_path(path)
|
||
&& windows_acl_error_may_need_elevation(&error)
|
||
&& (!error.contains("安全对象不属于当前用户") || {
|
||
#[cfg(test)]
|
||
{
|
||
path.file_name()
|
||
.is_some_and(|name| name == "game-creator.config.json")
|
||
|| path.starts_with(std::env::temp_dir())
|
||
|| path
|
||
.ancestors()
|
||
.any(|ancestor| ancestor.join(".agent/manifest.json").is_file())
|
||
}
|
||
#[cfg(not(test))]
|
||
{
|
||
false
|
||
}
|
||
}) =>
|
||
{
|
||
#[cfg(test)]
|
||
if error.contains("安全对象不属于当前用户") {
|
||
return secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
true,
|
||
);
|
||
}
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
true,
|
||
false,
|
||
)
|
||
}
|
||
Err(error) => Err(error),
|
||
};
|
||
}
|
||
#[cfg(not(test))]
|
||
{
|
||
let target_user_sid = current_windows_token_user_sid_string()?;
|
||
let mut attempted_targets = Vec::<PathBuf>::new();
|
||
loop {
|
||
match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) {
|
||
Ok(()) => return Ok(()),
|
||
Err(error) if windows_acl_error_may_need_elevation(&error) => {
|
||
if !scope.allows_path(path) {
|
||
return Err(error);
|
||
}
|
||
let repair_target = windows_acl_repair_target(path, scope);
|
||
if attempted_targets
|
||
.iter()
|
||
.any(|target| target == &repair_target)
|
||
{
|
||
return Err(format!(
|
||
"{error};自动提权修复重复命中同一目标,拒绝继续重试:{}",
|
||
repair_target.display()
|
||
));
|
||
}
|
||
attempted_targets.push(repair_target);
|
||
attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err(
|
||
|repair_error| format!("{error};自动提权修复未完成:{repair_error}"),
|
||
)?;
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
|
||
}
|
||
|
||
/// Initialize ownership only for a directory that was created by the current
|
||
/// operation. Existing directories must use the strict verifier instead, so a
|
||
/// foreign-owned path is never silently adopted.
|
||
#[cfg(windows)]
|
||
pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user(
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true)
|
||
}
|
||
|
||
/// Repairs an AGC-managed private object after an explicit UAC elevation.
|
||
/// Foreign-owned regular files/directories are deliberately reassigned to the
|
||
/// current token user here. The caller has already rejected links/reparse
|
||
/// points, and the final strict verification below is mandatory.
|
||
#[cfg(windows)]
|
||
pub(crate) fn repair_game_creator_private_acl_for_current_user(path: &Path) -> Result<(), String> {
|
||
let target_user_sid = current_windows_token_user_sid_string()?;
|
||
repair_game_creator_private_acl_for_user_sid(path, &target_user_sid)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn current_windows_token_user_sid_string() -> Result<String, String> {
|
||
use std::ffi::c_void;
|
||
|
||
type Handle = *mut c_void;
|
||
type Sid = *mut c_void;
|
||
|
||
#[repr(C)]
|
||
struct SidAndAttributes {
|
||
sid: Sid,
|
||
attributes: u32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TokenUser {
|
||
user: SidAndAttributes,
|
||
}
|
||
|
||
#[link(name = "advapi32")]
|
||
unsafe extern "system" {
|
||
fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
|
||
fn GetTokenInformation(
|
||
token: Handle,
|
||
information_class: u32,
|
||
information: *mut c_void,
|
||
information_length: u32,
|
||
return_length: *mut u32,
|
||
) -> i32;
|
||
fn ConvertSidToStringSidW(sid: Sid, string_sid: *mut *mut u16) -> i32;
|
||
}
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn GetCurrentProcess() -> Handle;
|
||
fn CloseHandle(handle: Handle) -> i32;
|
||
fn LocalFree(memory: *mut c_void) -> *mut c_void;
|
||
}
|
||
|
||
const TOKEN_QUERY: u32 = 0x0000_0008;
|
||
const TOKEN_USER_CLASS: u32 = 1;
|
||
|
||
let mut token = std::ptr::null_mut();
|
||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0
|
||
|| token.is_null()
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 token 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
let result = (|| {
|
||
let mut required = 0_u32;
|
||
unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
std::ptr::null_mut(),
|
||
0,
|
||
&mut required,
|
||
);
|
||
}
|
||
if required == 0 {
|
||
return Err("读取 Windows 当前用户 SID 长度失败".to_string());
|
||
}
|
||
let word_size = std::mem::size_of::<usize>();
|
||
let mut token_buffer = vec![0_usize; (required as usize).div_ceil(word_size)];
|
||
if unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
token_buffer.as_mut_ptr().cast(),
|
||
required,
|
||
&mut required,
|
||
)
|
||
} == 0
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 SID 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
let sid = unsafe { (*(token_buffer.as_ptr().cast::<TokenUser>())).user.sid };
|
||
if sid.is_null() {
|
||
return Err("Windows 当前用户 SID 无效".to_string());
|
||
}
|
||
let mut string_sid = std::ptr::null_mut();
|
||
if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 || string_sid.is_null() {
|
||
return Err(format!(
|
||
"转换 Windows 当前用户 SID 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
let mut length = 0_usize;
|
||
while unsafe { *string_sid.add(length) } != 0 {
|
||
length = length.saturating_add(1);
|
||
if length > 256 {
|
||
unsafe { LocalFree(string_sid.cast()) };
|
||
return Err("Windows 当前用户 SID 长度无效".to_string());
|
||
}
|
||
}
|
||
let value = String::from_utf16(unsafe { std::slice::from_raw_parts(string_sid, length) })
|
||
.map_err(|_| "Windows 当前用户 SID 编码无效".to_string());
|
||
unsafe { LocalFree(string_sid.cast()) };
|
||
value
|
||
})();
|
||
unsafe { CloseHandle(token) };
|
||
result
|
||
}
|
||
|
||
/// The elevated helper may run under administrator credentials that differ
|
||
/// from the original desktop user's token. Keep ownership and the private
|
||
/// DACL bound to the original TokenUser SID passed by the caller.
|
||
#[cfg(windows)]
|
||
pub(crate) fn repair_game_creator_private_acl_for_user_sid(
|
||
path: &Path,
|
||
target_user_sid: &str,
|
||
) -> Result<(), String> {
|
||
let metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("读取待修复私有对象失败:{}: {error}", path.display()))?;
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
|| metadata.file_type().is_symlink()
|
||
{
|
||
return Err("待修复私有对象不能是 Windows reparse point 或链接".to_string());
|
||
}
|
||
if !metadata.is_dir() && !metadata.is_file() {
|
||
return Err("待修复私有对象必须是普通文件或目录".to_string());
|
||
}
|
||
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
|
||
path,
|
||
metadata.is_dir(),
|
||
true,
|
||
true,
|
||
Some(target_user_sid),
|
||
)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn windows_acl_error_may_need_elevation(error: &str) -> bool {
|
||
(error.contains("DACL")
|
||
|| error.contains("权限")
|
||
|| error.contains("error 5")
|
||
|| error.contains("安全对象不属于当前用户")
|
||
|| error.contains("启用 Windows")
|
||
|| error.contains("特权")
|
||
|| error.contains("1300"))
|
||
&& !error.contains("链接")
|
||
&& !error.contains("reparse")
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathBuf {
|
||
// A user-selected object is the only trusted identity in that scope. Do
|
||
// not widen it to an unreadable parent (which could be another user's
|
||
// profile or a protected system directory); the helper will validate the
|
||
// selected regular object itself and fail closed if traversal remains
|
||
// impossible. Managed paths have an established AGC root and may repair
|
||
// the first blocked ancestor so inherited ACLs can be fixed in one pass.
|
||
if matches!(scope, WindowsAclRepairScope::UserSelected) {
|
||
return path.to_path_buf();
|
||
}
|
||
// Walk from the filesystem root towards the leaf. If traversal is denied
|
||
// on an ancestor, repairing the leaf cannot help because the elevated
|
||
// helper will hit the same ancestor before it can inspect the leaf.
|
||
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
|
||
match fs::symlink_metadata(ancestor) {
|
||
Ok(_) => {}
|
||
Err(error)
|
||
if error.kind() == std::io::ErrorKind::PermissionDenied
|
||
|| error.raw_os_error() == Some(5) =>
|
||
{
|
||
return ancestor.to_path_buf();
|
||
}
|
||
Err(_) => {}
|
||
}
|
||
}
|
||
path.to_path_buf()
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024;
|
||
|
||
#[cfg(windows)]
|
||
struct WindowsAclRepairAuthorizationCleanup {
|
||
path: PathBuf,
|
||
armed: bool,
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
impl WindowsAclRepairAuthorizationCleanup {
|
||
fn new(path: PathBuf) -> Self {
|
||
Self { path, armed: true }
|
||
}
|
||
|
||
fn disarm(&mut self) {
|
||
self.armed = false;
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
impl Drop for WindowsAclRepairAuthorizationCleanup {
|
||
fn drop(&mut self) {
|
||
if self.armed {
|
||
let _ = fs::remove_file(&self.path);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[derive(Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct WindowsAclRepairAuthorization {
|
||
path: String,
|
||
target_user_sid: String,
|
||
scope: String,
|
||
issued_at: u64,
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn windows_acl_repair_authorization_path(nonce: &str) -> Result<PathBuf, String> {
|
||
let nonce = nonce.trim();
|
||
if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||
return Err("AGC ACL 修复授权票据无效".to_string());
|
||
}
|
||
let temporary_directory = std::env::temp_dir();
|
||
if !temporary_directory.is_absolute() {
|
||
return Err("AGC ACL 修复临时目录必须是绝对路径".to_string());
|
||
}
|
||
Ok(temporary_directory.join(format!(".genarrative-acl-repair-{nonce}.json")))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn create_windows_acl_repair_authorization(
|
||
path: &Path,
|
||
target_user_sid: &str,
|
||
scope: WindowsAclRepairScope,
|
||
) -> Result<String, String> {
|
||
let nonce = uuid::Uuid::new_v4().simple().to_string();
|
||
let authorization_path = windows_acl_repair_authorization_path(&nonce)?;
|
||
let payload = serde_json::to_vec(&WindowsAclRepairAuthorization {
|
||
path: path.to_string_lossy().into_owned(),
|
||
target_user_sid: target_user_sid.to_string(),
|
||
scope: scope.wire_name().to_string(),
|
||
issued_at: unix_timestamp(),
|
||
})
|
||
.map_err(|error| format!("创建 AGC ACL 修复授权票据失败:{error}"))?;
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options.open(&authorization_path).map_err(|error| {
|
||
format!(
|
||
"创建 AGC ACL 修复授权票据文件失败:{}: {error}",
|
||
authorization_path.display()
|
||
)
|
||
})?;
|
||
// The ticket is created in the per-user TEMP directory while the normal
|
||
// process is not elevated. Do not call the auto-elevation wrapper here:
|
||
// that wrapper itself creates a ticket and would recurse indefinitely when
|
||
// TEMP inherits a broad DACL. The strict owner-policy path can still
|
||
// tighten an owner-correct inherited ACL and rejects a foreign TEMP owner.
|
||
if let Err(error) = secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
&authorization_path,
|
||
false,
|
||
true,
|
||
true,
|
||
) {
|
||
drop(file);
|
||
let _ = fs::remove_file(&authorization_path);
|
||
return Err(format!("初始化 AGC ACL 修复授权票据安全权限失败:{error}"));
|
||
}
|
||
if let Err(error) = file.write_all(&payload).and_then(|_| file.sync_all()) {
|
||
drop(file);
|
||
let _ = fs::remove_file(&authorization_path);
|
||
return Err(format!("写入 AGC ACL 修复授权票据失败:{error}"));
|
||
}
|
||
drop(file);
|
||
Ok(nonce)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn consume_windows_acl_repair_authorization(
|
||
path: &Path,
|
||
target_user_sid: &str,
|
||
nonce: &str,
|
||
scope: WindowsAclRepairScope,
|
||
) -> Result<(), String> {
|
||
if !scope.allows_path(path) {
|
||
return Err(format!(
|
||
"AGC ACL 修复目标不在当前用户允许的 {} 范围内:{}",
|
||
scope.wire_name(),
|
||
path.display()
|
||
));
|
||
}
|
||
let authorization_path = windows_acl_repair_authorization_path(nonce)?;
|
||
let mut cleanup = WindowsAclRepairAuthorizationCleanup::new(authorization_path.clone());
|
||
|
||
// Open the ticket before validating its contents. The security helper
|
||
// below uses GetNamedSecurityInfoW/SetNamedSecurityInfoW by pathname; an
|
||
// exclusive handle would make those calls fail with a sharing violation
|
||
// on Windows. Keep the ticket in the per-user TEMP directory with a
|
||
// hardened owner-only DACL, then validate the opened handle's metadata and
|
||
// exact payload before consuming the one-shot file.
|
||
use std::os::windows::fs::MetadataExt;
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
const FILE_SHARE_READ: u32 = 0x0000_0001;
|
||
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
|
||
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
|
||
use std::io::Read as _;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
let mut file = fs::OpenOptions::new()
|
||
.read(true)
|
||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||
.open(&authorization_path)
|
||
.map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?;
|
||
crate::runner::validate_windows_regular_file_handle(&file, "AGC ACL 修复授权票据")?;
|
||
let opened_metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("读取 AGC ACL 修复授权票据句柄元数据失败:{error}"))?;
|
||
if opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
|| !opened_metadata.is_file()
|
||
{
|
||
return Err("AGC ACL 修复授权票据必须是普通文件".to_string());
|
||
}
|
||
if opened_metadata.len() > WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES {
|
||
return Err("AGC ACL 修复授权票据过大".to_string());
|
||
}
|
||
// The elevated helper runs under an administrator token, so validate the
|
||
// ticket against the original desktop user's SID rather than the helper's
|
||
// current SID. This also rejects a ticket placed in TEMP by another user.
|
||
// This check deliberately happens after opening the ticket handle so the
|
||
// payload is tied to a real regular file before it is authorized.
|
||
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
|
||
&authorization_path,
|
||
false,
|
||
true,
|
||
false,
|
||
Some(target_user_sid),
|
||
)?;
|
||
|
||
let mut content = String::new();
|
||
file.read_to_string(&mut content)
|
||
.map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?;
|
||
let final_metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("复核 AGC ACL 修复授权票据句柄元数据失败:{error}"))?;
|
||
if final_metadata.len() != opened_metadata.len() {
|
||
return Err("AGC ACL 修复授权票据读取期间发生漂移".to_string());
|
||
}
|
||
let authorization = serde_json::from_str::<WindowsAclRepairAuthorization>(&content)
|
||
.map_err(|error| format!("AGC ACL 修复授权票据格式无效:{error}"))?;
|
||
if unix_timestamp().saturating_sub(authorization.issued_at) > 300 {
|
||
return Err("AGC ACL 修复授权票据已过期".to_string());
|
||
}
|
||
if authorization.path != path.to_string_lossy()
|
||
|| authorization.target_user_sid != target_user_sid
|
||
|| authorization.scope != scope.wire_name()
|
||
{
|
||
return Err("AGC ACL 修复授权票据与目标不匹配".to_string());
|
||
}
|
||
|
||
// Release the read handle before consuming the one-shot file. The cleanup
|
||
// guard retries removal on every failure path, while a successful removal
|
||
// disarms it to avoid a second delete attempt during unwinding.
|
||
drop(file);
|
||
fs::remove_file(&authorization_path)
|
||
.map_err(|error| format!("删除 AGC ACL 修复授权票据失败:{error}"))?;
|
||
cleanup.disarm();
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn windows_command_line_quote(value: &str) -> String {
|
||
format!("\"{}\"", value.replace('"', "\\\""))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn windows_acl_repair_argument_list(
|
||
path: &str,
|
||
target_user_sid: &str,
|
||
nonce: &str,
|
||
scope: WindowsAclRepairScope,
|
||
) -> String {
|
||
[
|
||
"--repair-private-acl",
|
||
path,
|
||
"--target-user-sid",
|
||
target_user_sid,
|
||
"--authorization",
|
||
nonce,
|
||
"--scope",
|
||
scope.wire_name(),
|
||
]
|
||
.into_iter()
|
||
.map(windows_command_line_quote)
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
|
||
/// Starts a one-shot elevated copy of the current executable. The elevated
|
||
/// process performs only the allow-listed ACL repair command and exits with a
|
||
/// truthful status; UAC cancellation is never treated as success.
|
||
#[cfg(windows)]
|
||
fn attempt_elevated_windows_acl_repair(
|
||
path: &Path,
|
||
target_user_sid: &str,
|
||
scope: WindowsAclRepairScope,
|
||
) -> Result<(), String> {
|
||
if !scope.allows_path(path) {
|
||
return Err(format!(
|
||
"AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}",
|
||
scope.wire_name(),
|
||
path.display()
|
||
));
|
||
}
|
||
let executable =
|
||
std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?;
|
||
if !executable.is_file() {
|
||
return Err("AGC ACL 修复程序不存在".to_string());
|
||
}
|
||
let repair_path = windows_acl_repair_target(path, scope);
|
||
let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?;
|
||
let escaped_executable = executable.to_string_lossy().replace('\'', "''");
|
||
let arguments = windows_acl_repair_argument_list(
|
||
&repair_path.to_string_lossy(),
|
||
target_user_sid,
|
||
&nonce,
|
||
scope,
|
||
)
|
||
.replace('\'', "''");
|
||
let script = format!(
|
||
"$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList '{arguments}'; if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}"
|
||
);
|
||
use std::os::windows::process::CommandExt;
|
||
let status = std::process::Command::new("powershell.exe")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-WindowStyle",
|
||
"Hidden",
|
||
"-Command",
|
||
script.as_str(),
|
||
])
|
||
.creation_flags(0x0800_0000)
|
||
.status()
|
||
.map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}"));
|
||
let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| {
|
||
fs::remove_file(authorization_path).map_err(|error| error.to_string())
|
||
});
|
||
let status = status?;
|
||
if status.success() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!(
|
||
"AGC ACL 提权修复未成功(exit code {:?})",
|
||
status.code()
|
||
))
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn windows_private_dacl_security_information(
|
||
initialize_owner: bool,
|
||
owner_matches: bool,
|
||
) -> u32 {
|
||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
|
||
|
||
DACL_SECURITY_INFORMATION
|
||
| PROTECTED_DACL_SECURITY_INFORMATION
|
||
| if initialize_owner && !owner_matches {
|
||
OWNER_SECURITY_INFORMATION
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn windows_security_object_path(path: &Path) -> std::ffi::OsString {
|
||
use std::ffi::OsString;
|
||
|
||
let raw = path.as_os_str().to_string_lossy();
|
||
// GetNamedSecurityInfoW/SetNamedSecurityInfoW report ERROR_INVALID_NAME
|
||
// for ordinary absolute paths at the MAX_PATH boundary. Extended-length
|
||
// paths are accepted by these APIs and preserve the exact object identity.
|
||
// Do not add the prefix twice, and translate UNC paths to the documented
|
||
// \\?\UNC\server\share form.
|
||
if raw.starts_with("\\\\?\\") || raw.encode_utf16().count() < 260 {
|
||
return path.as_os_str().to_os_string();
|
||
}
|
||
if let Some(unc) = raw.strip_prefix("\\\\") {
|
||
OsString::from(format!("\\\\?\\UNC\\{unc}"))
|
||
} else {
|
||
OsString::from(format!("\\\\?\\{raw}"))
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
initialize_owner: bool,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
tighten,
|
||
initialize_owner,
|
||
None,
|
||
)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn secure_windows_game_creator_path_for_user_sid_with_owner_policy(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
initialize_owner: bool,
|
||
target_user_sid: Option<&str>,
|
||
) -> Result<(), String> {
|
||
use std::ffi::c_void;
|
||
use std::os::windows::ffi::OsStrExt;
|
||
|
||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"读取 Windows 私有对象元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!(
|
||
"Windows 私有对象不能是符号链接:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"Windows 私有对象不能是 reparse point:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
if (is_directory && !metadata.is_dir()) || (!is_directory && !metadata.is_file()) {
|
||
return Err(format!(
|
||
"Windows 私有对象类型不符合预期:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
validate_game_creator_private_path_ancestors(path, "Windows 私有对象")?;
|
||
|
||
type Handle = *mut c_void;
|
||
type Sid = *mut c_void;
|
||
|
||
#[repr(C)]
|
||
struct SidAndAttributes {
|
||
sid: Sid,
|
||
attributes: u32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TokenUser {
|
||
user: SidAndAttributes,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TrusteeW {
|
||
multiple_trustee: *mut TrusteeW,
|
||
multiple_trustee_operation: i32,
|
||
trustee_form: i32,
|
||
trustee_type: i32,
|
||
name: *mut u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct ExplicitAccessW {
|
||
access_permissions: u32,
|
||
access_mode: i32,
|
||
inheritance: u32,
|
||
trustee: TrusteeW,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct Acl {
|
||
revision: u8,
|
||
reserved: u8,
|
||
size: u16,
|
||
ace_count: u16,
|
||
reserved2: u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct AceHeader {
|
||
ace_type: u8,
|
||
ace_flags: u8,
|
||
ace_size: u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct AccessAllowedAce {
|
||
header: AceHeader,
|
||
mask: u32,
|
||
sid_start: u32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct Luid {
|
||
low_part: u32,
|
||
high_part: i32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct LuidAndAttributes {
|
||
luid: Luid,
|
||
attributes: u32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TokenPrivileges {
|
||
privilege_count: u32,
|
||
privileges: [LuidAndAttributes; 1],
|
||
}
|
||
|
||
#[link(name = "advapi32")]
|
||
unsafe extern "system" {
|
||
fn GetNamedSecurityInfoW(
|
||
object_name: *mut u16,
|
||
object_type: u32,
|
||
security_info: u32,
|
||
owner: *mut Sid,
|
||
group: *mut Sid,
|
||
dacl: *mut *mut c_void,
|
||
sacl: *mut *mut c_void,
|
||
descriptor: *mut *mut c_void,
|
||
) -> u32;
|
||
fn SetNamedSecurityInfoW(
|
||
object_name: *mut u16,
|
||
object_type: u32,
|
||
security_info: u32,
|
||
owner: Sid,
|
||
group: Sid,
|
||
dacl: *mut c_void,
|
||
sacl: *mut c_void,
|
||
) -> u32;
|
||
fn SetEntriesInAclW(
|
||
entry_count: u32,
|
||
entries: *mut ExplicitAccessW,
|
||
old_acl: *mut c_void,
|
||
new_acl: *mut *mut c_void,
|
||
) -> u32;
|
||
fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
|
||
fn LookupPrivilegeValueW(system_name: *const u16, name: *const u16, luid: *mut Luid)
|
||
-> i32;
|
||
fn AdjustTokenPrivileges(
|
||
token: Handle,
|
||
disable_all_privileges: i32,
|
||
new_state: *mut TokenPrivileges,
|
||
buffer_length: u32,
|
||
previous_state: *mut TokenPrivileges,
|
||
return_length: *mut u32,
|
||
) -> i32;
|
||
fn GetTokenInformation(
|
||
token: Handle,
|
||
information_class: u32,
|
||
information: *mut c_void,
|
||
information_length: u32,
|
||
return_length: *mut u32,
|
||
) -> i32;
|
||
fn ConvertStringSidToSidW(string_sid: *const u16, sid: *mut Sid) -> i32;
|
||
fn EqualSid(first: Sid, second: Sid) -> i32;
|
||
fn IsValidSid(sid: Sid) -> i32;
|
||
fn GetLengthSid(sid: Sid) -> u32;
|
||
fn IsValidAcl(acl: *mut c_void) -> i32;
|
||
fn GetAce(acl: *mut c_void, index: u32, ace: *mut *mut c_void) -> i32;
|
||
fn GetSecurityDescriptorControl(
|
||
descriptor: *mut c_void,
|
||
control: *mut u16,
|
||
revision: *mut u32,
|
||
) -> i32;
|
||
}
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn GetCurrentProcess() -> Handle;
|
||
fn CloseHandle(handle: Handle) -> i32;
|
||
fn LocalFree(memory: *mut c_void) -> *mut c_void;
|
||
fn GetLastError() -> u32;
|
||
}
|
||
|
||
const SE_FILE_OBJECT: u32 = 1;
|
||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||
const SE_DACL_PROTECTED: u16 = 0x1000;
|
||
const TOKEN_QUERY: u32 = 0x0000_0008;
|
||
const TOKEN_ADJUST_PRIVILEGES: u32 = 0x0000_0020;
|
||
const TOKEN_USER_CLASS: u32 = 1;
|
||
const SE_PRIVILEGE_ENABLED: u32 = 0x0000_0002;
|
||
const ERROR_NOT_ALL_ASSIGNED: u32 = 1300;
|
||
const SET_ACCESS: i32 = 2;
|
||
const TRUSTEE_IS_SID: i32 = 0;
|
||
const TRUSTEE_IS_USER: i32 = 1;
|
||
const FILE_ALL_ACCESS: u32 = 0x001f_01ff;
|
||
const OBJECT_INHERIT_ACE: u8 = 0x01;
|
||
const CONTAINER_INHERIT_ACE: u8 = 0x02;
|
||
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00;
|
||
|
||
let mut token = std::ptr::null_mut();
|
||
// SAFETY: GetCurrentProcess returns a valid pseudo handle and token is a valid output pointer.
|
||
if unsafe {
|
||
OpenProcessToken(
|
||
GetCurrentProcess(),
|
||
TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES,
|
||
&mut token,
|
||
)
|
||
} == 0
|
||
|| token.is_null()
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 token 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
|
||
let mut requested_user_sid = std::ptr::null_mut();
|
||
let result = (|| {
|
||
let mut required = 0_u32;
|
||
// SAFETY: the null query buffer is the documented size-probe call.
|
||
unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
std::ptr::null_mut(),
|
||
0,
|
||
&mut required,
|
||
)
|
||
};
|
||
if required == 0 {
|
||
return Err("读取 Windows 当前用户 SID 长度失败".to_string());
|
||
}
|
||
let word_size = std::mem::size_of::<usize>();
|
||
let mut token_buffer = vec![0_usize; (required as usize).div_ceil(word_size)];
|
||
// SAFETY: the aligned token buffer is at least the probed TOKEN_USER size.
|
||
if unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
token_buffer.as_mut_ptr().cast(),
|
||
required,
|
||
&mut required,
|
||
)
|
||
} == 0
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 SID 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: GetTokenInformation populated TOKEN_USER at the aligned buffer start.
|
||
let current_user_sid = unsafe { (*(token_buffer.as_ptr().cast::<TokenUser>())).user.sid };
|
||
if current_user_sid.is_null() || unsafe { IsValidSid(current_user_sid) } == 0 {
|
||
return Err("Windows 当前用户 SID 无效".to_string());
|
||
}
|
||
let target_user_sid = if let Some(target_user_sid) = target_user_sid {
|
||
let target_user_sid = target_user_sid.trim();
|
||
if target_user_sid.is_empty() {
|
||
return Err("Windows ACL 修复目标 TokenUser SID 不能为空".to_string());
|
||
}
|
||
let mut wide_target_user_sid = target_user_sid
|
||
.encode_utf16()
|
||
.chain(std::iter::once(0))
|
||
.collect::<Vec<_>>();
|
||
if unsafe {
|
||
ConvertStringSidToSidW(wide_target_user_sid.as_mut_ptr(), &mut requested_user_sid)
|
||
} == 0
|
||
|| requested_user_sid.is_null()
|
||
|| unsafe { IsValidSid(requested_user_sid) } == 0
|
||
{
|
||
return Err("Windows ACL 修复目标 TokenUser SID 无效".to_string());
|
||
}
|
||
requested_user_sid
|
||
} else {
|
||
current_user_sid
|
||
};
|
||
|
||
// Security APIs otherwise reject a perfectly valid private sidecar at
|
||
// the MAX_PATH boundary with ERROR_INVALID_NAME. Use the extended
|
||
// length spelling only when needed; short paths retain the ordinary
|
||
// Win32 form for compatibility with older Windows builds.
|
||
let security_path = windows_security_object_path(path);
|
||
let mut wide_path = security_path
|
||
.as_os_str()
|
||
.encode_wide()
|
||
.chain(std::iter::once(0))
|
||
.collect::<Vec<_>>();
|
||
let mut initial_owner = std::ptr::null_mut();
|
||
let mut initial_descriptor = std::ptr::null_mut();
|
||
// 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。
|
||
let owner_status = unsafe {
|
||
GetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
OWNER_SECURITY_INFORMATION,
|
||
&mut initial_owner,
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
&mut initial_descriptor,
|
||
)
|
||
};
|
||
if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() {
|
||
if !initial_descriptor.is_null() {
|
||
unsafe { LocalFree(initial_descriptor) };
|
||
}
|
||
return Err(format!(
|
||
"读取 Windows owner 失败:{}: error {owner_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
let owner_matches = unsafe { IsValidSid(initial_owner) } != 0
|
||
&& unsafe { EqualSid(initial_owner, target_user_sid) } != 0;
|
||
unsafe { LocalFree(initial_descriptor) };
|
||
if !owner_matches {
|
||
if !(initialize_owner && tighten) {
|
||
return Err(format!(
|
||
"Windows 安全对象不属于当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
|
||
// Assigning ownership to the current token user requires the
|
||
// take-ownership/restore privileges on an elevated process.
|
||
// Enabling them is attempted only for this allow-listed repair;
|
||
// a normal process will fail and the caller will request UAC
|
||
// elevation instead of weakening the verifier.
|
||
for privilege_name in ["SeTakeOwnershipPrivilege", "SeRestorePrivilege"] {
|
||
let mut privilege_name_wide = privilege_name
|
||
.encode_utf16()
|
||
.chain(std::iter::once(0))
|
||
.collect::<Vec<_>>();
|
||
let mut luid = Luid {
|
||
low_part: 0,
|
||
high_part: 0,
|
||
};
|
||
if unsafe {
|
||
LookupPrivilegeValueW(
|
||
std::ptr::null(),
|
||
privilege_name_wide.as_mut_ptr(),
|
||
&mut luid,
|
||
)
|
||
} == 0
|
||
{
|
||
return Err(format!(
|
||
"启用 Windows {privilege_name} 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
let mut privileges = TokenPrivileges {
|
||
privilege_count: 1,
|
||
privileges: [LuidAndAttributes {
|
||
luid,
|
||
attributes: SE_PRIVILEGE_ENABLED,
|
||
}],
|
||
};
|
||
let adjust_status = unsafe {
|
||
AdjustTokenPrivileges(
|
||
token,
|
||
0,
|
||
&mut privileges,
|
||
std::mem::size_of::<TokenPrivileges>() as u32,
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
)
|
||
};
|
||
let adjust_error = unsafe { GetLastError() };
|
||
if adjust_status == 0 || adjust_error == ERROR_NOT_ALL_ASSIGNED {
|
||
return Err(format!(
|
||
"启用 Windows {privilege_name} 失败:error {adjust_error}"
|
||
));
|
||
}
|
||
}
|
||
}
|
||
if tighten {
|
||
let mut entry = ExplicitAccessW {
|
||
access_permissions: FILE_ALL_ACCESS,
|
||
access_mode: SET_ACCESS,
|
||
inheritance: if is_directory {
|
||
(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u32
|
||
} else {
|
||
0
|
||
},
|
||
trustee: TrusteeW {
|
||
multiple_trustee: std::ptr::null_mut(),
|
||
multiple_trustee_operation: 0,
|
||
trustee_form: TRUSTEE_IS_SID,
|
||
trustee_type: TRUSTEE_IS_USER,
|
||
name: target_user_sid.cast(),
|
||
},
|
||
};
|
||
let mut private_dacl = std::ptr::null_mut();
|
||
// SAFETY: entry points at the current token SID for the duration of this call.
|
||
let acl_status =
|
||
unsafe { SetEntriesInAclW(1, &mut entry, std::ptr::null_mut(), &mut private_dacl) };
|
||
if acl_status != 0 || private_dacl.is_null() {
|
||
return Err(format!(
|
||
"构造 Windows 当前用户私有 DACL 失败:{}: error {acl_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW.
|
||
let should_initialize_owner = initialize_owner && !owner_matches;
|
||
let set_status = unsafe {
|
||
SetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
windows_private_dacl_security_information(initialize_owner, owner_matches),
|
||
if should_initialize_owner {
|
||
target_user_sid
|
||
} else {
|
||
std::ptr::null_mut()
|
||
},
|
||
std::ptr::null_mut(),
|
||
private_dacl,
|
||
std::ptr::null_mut(),
|
||
)
|
||
};
|
||
// SAFETY: private_dacl was allocated by SetEntriesInAclW.
|
||
unsafe { LocalFree(private_dacl) };
|
||
if set_status != 0 {
|
||
return Err(format!(
|
||
"初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
let mut owner = std::ptr::null_mut();
|
||
let mut dacl = std::ptr::null_mut();
|
||
let mut descriptor = std::ptr::null_mut();
|
||
// SAFETY: all output pointers are valid and wide_path remains NUL terminated.
|
||
let security_status = unsafe {
|
||
GetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||
&mut owner,
|
||
std::ptr::null_mut(),
|
||
&mut dacl,
|
||
std::ptr::null_mut(),
|
||
&mut descriptor,
|
||
)
|
||
};
|
||
if security_status != 0 || owner.is_null() || dacl.is_null() || descriptor.is_null() {
|
||
if !descriptor.is_null() {
|
||
// SAFETY: descriptor was allocated by GetNamedSecurityInfoW.
|
||
unsafe { LocalFree(descriptor) };
|
||
}
|
||
return Err(format!(
|
||
"读取 Windows owner/DACL 失败:{}: error {security_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
|
||
let validation = (|| {
|
||
if unsafe { IsValidSid(owner) } == 0 || unsafe { EqualSid(owner, target_user_sid) } == 0
|
||
{
|
||
return Err(format!(
|
||
"Windows 安全对象不属于当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
if unsafe { IsValidAcl(dacl) } == 0 {
|
||
return Err(format!("Windows DACL 无效:{}", path.display()));
|
||
}
|
||
let mut control = 0_u16;
|
||
let mut revision = 0_u32;
|
||
// SAFETY: descriptor is a valid self-relative security descriptor.
|
||
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0
|
||
|| control & SE_DACL_PROTECTED == 0
|
||
{
|
||
return Err(format!(
|
||
"Windows DACL 必须禁止继承并仅限当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: IsValidAcl succeeded, so its fixed ACL header is readable.
|
||
let acl = unsafe { &*(dacl.cast::<Acl>()) };
|
||
if acl.ace_count != 1 {
|
||
return Err(format!(
|
||
"Windows DACL 必须且只能包含当前用户 ACE:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
let mut ace = std::ptr::null_mut();
|
||
// SAFETY: dacl is valid and index zero exists because ace_count is one.
|
||
if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() {
|
||
return Err(format!("读取 Windows DACL ACE 失败:{}", path.display()));
|
||
}
|
||
// SAFETY: GetAce returned at least a valid ACE_HEADER from the validated ACL.
|
||
let header = unsafe { &*(ace.cast::<AceHeader>()) };
|
||
if header.ace_type != ACCESS_ALLOWED_ACE_TYPE
|
||
|| usize::from(header.ace_size) < std::mem::size_of::<AccessAllowedAce>()
|
||
{
|
||
return Err(format!(
|
||
"Windows DACL 当前用户 ACE 权限无效:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: the ACE type and size now prove the full ACCESS_ALLOWED_ACE header exists.
|
||
let allowed = unsafe { &*(ace.cast::<AccessAllowedAce>()) };
|
||
if allowed.mask != FILE_ALL_ACCESS {
|
||
return Err(format!(
|
||
"Windows DACL 当前用户 ACE 权限无效:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
let required_inheritance = if is_directory {
|
||
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||
} else {
|
||
0
|
||
};
|
||
if allowed.header.ace_flags != required_inheritance {
|
||
return Err(format!("Windows DACL 继承边界无效:{}", path.display()));
|
||
}
|
||
let ace_sid = std::ptr::addr_of!(allowed.sid_start)
|
||
.cast_mut()
|
||
.cast::<c_void>();
|
||
let sid_length = unsafe { GetLengthSid(ace_sid) } as usize;
|
||
let sid_offset = std::mem::size_of::<AceHeader>() + std::mem::size_of::<u32>();
|
||
if sid_length == 0
|
||
|| sid_offset.saturating_add(sid_length) > usize::from(header.ace_size)
|
||
|| unsafe { IsValidSid(ace_sid) } == 0
|
||
|| unsafe { EqualSid(ace_sid, target_user_sid) } == 0
|
||
{
|
||
return Err(format!(
|
||
"Windows DACL 当前用户 ACE 身份无效:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
Ok(())
|
||
})();
|
||
// SAFETY: descriptor was allocated by GetNamedSecurityInfoW.
|
||
unsafe { LocalFree(descriptor) };
|
||
validation
|
||
})();
|
||
|
||
// SAFETY: token was opened successfully above.
|
||
if !requested_user_sid.is_null() {
|
||
// SAFETY: ConvertStringSidToSidW allocates this SID with LocalAlloc.
|
||
unsafe { LocalFree(requested_user_sid.cast()) };
|
||
}
|
||
unsafe { CloseHandle(token) };
|
||
result
|
||
}
|
||
|
||
pub(crate) fn configure_game_creator_runtime_config_dir(
|
||
app: &tauri::AppHandle,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
let requested_config_dir = game_creator_runtime_config_dir()
|
||
.map(Ok)
|
||
.unwrap_or_else(|| app.path().app_config_dir())?;
|
||
let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir)
|
||
.map_err(std::io::Error::other)?;
|
||
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||
let config_exists =
|
||
validate_game_creator_config_file_entry(&config_path).map_err(std::io::Error::other)?;
|
||
if !config_exists {
|
||
write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
// 按主配置与本地覆盖的最终开关决定是否保留自定义连接。
|
||
for path in [
|
||
config_path,
|
||
config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
] {
|
||
migrate_legacy_game_creator_agent_mode(&path).map_err(std::io::Error::other)?;
|
||
}
|
||
set_game_creator_runtime_config_dir(config_dir);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates a persisted AGC config file without following links.
|
||
///
|
||
/// Reading configuration must remain read-only. ACL hardening is performed
|
||
/// when the file is created or replaced, not on every settings-panel read.
|
||
fn validate_game_creator_config_file_entry(path: &Path) -> Result<bool, String> {
|
||
#[cfg(windows)]
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?;
|
||
#[cfg(not(windows))]
|
||
validate_game_creator_private_path_ancestors(path, "客户端配置文件")?;
|
||
let metadata = match fs::symlink_metadata(path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取客户端配置文件元数据失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string());
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
pub(crate) fn legacy_game_creator_agent_mode(
|
||
config: &GameCreatorAppConfigFile,
|
||
) -> Option<&'static str> {
|
||
if config.agent_mode.is_some() {
|
||
return None;
|
||
}
|
||
let responses_only = config
|
||
.llm
|
||
.as_ref()
|
||
.and_then(|llm| llm.api_kind.as_deref())
|
||
.map(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses")
|
||
.unwrap_or(true)
|
||
&& config
|
||
.agent_llm
|
||
.as_ref()
|
||
.into_iter()
|
||
.flat_map(|agents| agents.values())
|
||
.filter_map(|llm| llm.api_kind.as_deref())
|
||
.all(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses");
|
||
Some(if responses_only {
|
||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
||
} else {
|
||
GAME_CREATOR_AGENT_MODE_PROVIDER
|
||
})
|
||
}
|
||
|
||
pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
|
||
GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None))
|
||
}
|
||
|
||
pub(crate) fn set_game_creator_runtime_config_dir(path: PathBuf) {
|
||
*game_creator_runtime_config_dir_lock()
|
||
.lock()
|
||
.expect("runtime config dir lock") = Some(path);
|
||
}
|
||
|
||
pub(crate) fn game_creator_runtime_config_dir() -> Option<PathBuf> {
|
||
game_creator_runtime_config_dir_lock()
|
||
.lock()
|
||
.expect("runtime config dir lock")
|
||
.clone()
|
||
}
|
||
|
||
pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, String> {
|
||
let mut config = GameCreatorAppConfig::default();
|
||
for path in game_creator_config_paths() {
|
||
merge_game_creator_config_file(&mut config, &path)?;
|
||
}
|
||
if game_creator_official_llm_route_locked() {
|
||
lock_game_creator_app_config_to_official_route(&mut config);
|
||
}
|
||
apply_custom_llm_model_selection(&mut config);
|
||
Ok(config)
|
||
}
|
||
|
||
pub(crate) fn load_game_creator_app_config_for_write(
|
||
) -> Result<(GameCreatorAppConfig, Vec<(PathBuf, serde_json::Value)>), String> {
|
||
let writable_path = writable_game_creator_config_path()?;
|
||
let mut config = GameCreatorAppConfig::default();
|
||
let mut overlays = Vec::new();
|
||
let mut after_writable = false;
|
||
for path in game_creator_config_paths() {
|
||
if path == writable_path {
|
||
after_writable = true;
|
||
}
|
||
if let Some(content) = read_game_creator_config_file(&path)? {
|
||
merge_game_creator_config_content(&mut config, &path, &content)?;
|
||
if after_writable && path != writable_path {
|
||
let value = serde_json::from_str(&content)
|
||
.map_err(|error| format!("解析客户端覆盖配置失败:{error}"))?;
|
||
overlays.push((path, value));
|
||
}
|
||
}
|
||
}
|
||
if game_creator_official_llm_route_locked() {
|
||
lock_game_creator_app_config_to_official_route(&mut config);
|
||
}
|
||
apply_custom_llm_model_selection(&mut config);
|
||
Ok((config, overlays))
|
||
}
|
||
|
||
pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppConfigFile) -> bool {
|
||
if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) {
|
||
return false;
|
||
}
|
||
let mut changed = config.agent_mode.as_deref()
|
||
!= Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
||
|| config.agent_llm.is_some();
|
||
config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string());
|
||
config.agent_llm = None;
|
||
if let Some(llm) = config.llm.as_mut() {
|
||
// 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时
|
||
// 用户能看到 baseUrl / apiKey / model / apiKind 四要素与开关、模型列表并列。
|
||
let official_model = config
|
||
.selected_model_id
|
||
.clone()
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string());
|
||
changed |= llm.api_key.as_deref() != Some("")
|
||
|| llm.base_url.as_deref() != Some(OFFICIAL_LLM_ROUTER_BASE_URL)
|
||
|| llm.model.as_deref() != Some(official_model.as_str())
|
||
|| llm.api_kind.as_deref() != Some(DEFAULT_GAME_CREATOR_LLM_API_KIND)
|
||
|| llm.custom_enabled.is_none()
|
||
|| llm.visible_models.is_none();
|
||
llm.api_key = Some(String::new());
|
||
llm.base_url = Some(OFFICIAL_LLM_ROUTER_BASE_URL.to_string());
|
||
llm.model = Some(official_model);
|
||
llm.api_kind = Some(DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string());
|
||
llm.custom_enabled = Some(false);
|
||
llm.visible_models = Some(llm.visible_models.take().unwrap_or_default());
|
||
}
|
||
if config.editor_api.is_some() {
|
||
changed = true;
|
||
config.editor_api = None;
|
||
}
|
||
changed
|
||
}
|
||
|
||
pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
|
||
if !validate_game_creator_config_file_entry(path)? {
|
||
return Ok(());
|
||
}
|
||
let content = read_game_creator_private_file_to_string(path, "客户端配置文件", 256 * 1024)?;
|
||
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
|
||
let mut changed = false;
|
||
changed |= ensure_game_creator_custom_llm_file_fields(&mut config);
|
||
let inferred_agent_mode = config
|
||
.agent_mode
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
.or_else(|| legacy_game_creator_agent_mode(&config).map(str::to_string));
|
||
match config.schema_version.as_deref().map(str::trim) {
|
||
None => {
|
||
if inferred_agent_mode.as_deref() == Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) {
|
||
if let Some(llm) = config.llm.as_mut() {
|
||
if llm.web_search_enabled.is_none() {
|
||
llm.web_search_enabled = Some(true);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
config.agent_mode = Some(
|
||
inferred_agent_mode
|
||
.clone()
|
||
.unwrap_or_else(default_game_creator_agent_mode),
|
||
);
|
||
config.schema_version = Some(GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string());
|
||
changed = true;
|
||
}
|
||
Some(GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION) => {
|
||
if config.agent_mode.as_deref().is_none() {
|
||
config.agent_mode = Some(
|
||
inferred_agent_mode
|
||
.clone()
|
||
.unwrap_or_else(default_game_creator_agent_mode),
|
||
);
|
||
changed = true;
|
||
}
|
||
if inferred_agent_mode.as_deref() == Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
||
&& config
|
||
.llm
|
||
.as_ref()
|
||
.is_some_and(|llm| llm.web_search_enabled.is_none())
|
||
{
|
||
config.llm.as_mut().expect("checked llm").web_search_enabled = Some(true);
|
||
changed = true;
|
||
}
|
||
}
|
||
Some(version) => {
|
||
return Err(format!(
|
||
"客户端配置 schemaVersion 不支持:{};请升级陶泥儿",
|
||
version
|
||
));
|
||
}
|
||
}
|
||
if game_creator_official_llm_route_locked() && !custom_llm_enabled_at_config_path(path)? {
|
||
changed |= scrub_locked_game_creator_config_file(&mut config);
|
||
}
|
||
if changed {
|
||
let content = serde_json::to_string_pretty(&config)
|
||
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||
write_game_creator_config_atomically(path, &format!("{content}\n"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 让文件始终带 `customEnabled` 与 `visibleModels`:自定义连接靠手写这些字段开启,
|
||
/// 键缺席时用户无法从文件本身看出开关和模型列表写在哪里。
|
||
pub(crate) fn ensure_game_creator_custom_llm_file_fields(
|
||
config: &mut GameCreatorAppConfigFile,
|
||
) -> bool {
|
||
let Some(llm) = config.llm.as_mut() else {
|
||
return false;
|
||
};
|
||
let mut changed = false;
|
||
if llm.custom_enabled.is_none() {
|
||
llm.custom_enabled = Some(false);
|
||
changed = true;
|
||
}
|
||
if llm.visible_models.is_none() {
|
||
llm.visible_models = Some(Vec::new());
|
||
changed = true;
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// 默认官方路由策略;显式 llm.customEnabled 由调用方优先处理。
|
||
///
|
||
/// Debug and release binaries intentionally share this decision. The two
|
||
/// test exceptions are the Rust unit-test build and the explicitly env-gated debug
|
||
/// deterministic-provider E2E; their loopback fixtures are never compiled into
|
||
/// or enabled inside a shipped release binary.
|
||
pub(crate) fn game_creator_official_llm_route_locked() -> bool {
|
||
!debug_provider_e2e_route_unlocked()
|
||
&& game_creator_official_llm_route_locked_for_build(cfg!(test))
|
||
}
|
||
|
||
pub(crate) fn game_creator_official_llm_route_locked_for_build(is_test_build: bool) -> bool {
|
||
!is_test_build
|
||
}
|
||
|
||
fn debug_provider_e2e_route_unlocked() -> bool {
|
||
debug_provider_e2e_route_unlocked_for_build(
|
||
cfg!(debug_assertions),
|
||
std::env::var_os("GENARRATIVE_AGC_DEBUG_PROVIDER_E2E").as_deref(),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn debug_provider_e2e_route_unlocked_for_build(
|
||
debug_assertions: bool,
|
||
value: Option<&std::ffi::OsStr>,
|
||
) -> bool {
|
||
debug_assertions && value == Some(std::ffi::OsStr::new("1"))
|
||
}
|
||
|
||
pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCreatorAppConfig) {
|
||
if !game_creator_official_llm_route_locked() {
|
||
return;
|
||
}
|
||
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
|
||
config.agent_llm.clear();
|
||
config.editor_api.api_key.clear();
|
||
if config.llm.custom_enabled {
|
||
return;
|
||
}
|
||
config.llm.api_key.clear();
|
||
config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string();
|
||
config.llm.model = if config.selected_model_id.is_empty() {
|
||
OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string()
|
||
} else {
|
||
config.selected_model_id.clone()
|
||
};
|
||
config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string();
|
||
}
|
||
|
||
pub(crate) fn game_creator_app_config_view(
|
||
mut config: GameCreatorAppConfig,
|
||
) -> Result<GameCreatorAppConfigView, String> {
|
||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||
config.editor_api = GameCreatorEditorApiConfig::default();
|
||
}
|
||
Ok(GameCreatorAppConfigView {
|
||
path: writable_game_creator_config_path()?.display().to_string(),
|
||
config,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn serialize_game_creator_app_config_for_renderer_write(
|
||
config: &GameCreatorAppConfig,
|
||
) -> Result<String, String> {
|
||
let mut value =
|
||
serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||
value
|
||
.as_object_mut()
|
||
.ok_or_else(|| "客户端配置必须是 JSON object".to_string())?
|
||
.remove("editorApi");
|
||
}
|
||
serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
|
||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
|
||
}
|
||
std::env::current_dir()
|
||
.map(|directory| directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))
|
||
.map_err(|error| format!("读取当前目录失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn game_creator_config_paths() -> Vec<PathBuf> {
|
||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||
return vec![
|
||
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
];
|
||
}
|
||
|
||
let mut roots = Vec::new();
|
||
if let Ok(cwd) = std::env::current_dir() {
|
||
roots.push(cwd);
|
||
}
|
||
if let Ok(exe) = std::env::current_exe() {
|
||
if let Some(parent) = exe.parent() {
|
||
roots.push(parent.to_path_buf());
|
||
}
|
||
}
|
||
|
||
let mut default_paths = Vec::new();
|
||
let mut local_paths = Vec::new();
|
||
for root in roots {
|
||
let ancestors = root.ancestors().take(8).collect::<Vec<_>>();
|
||
for directory in ancestors.into_iter().rev() {
|
||
push_unique_path(
|
||
&mut default_paths,
|
||
directory.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut default_paths,
|
||
directory
|
||
.join("apps")
|
||
.join("ai-game-creator-shell")
|
||
.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut local_paths,
|
||
directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut local_paths,
|
||
directory
|
||
.join("apps")
|
||
.join("ai-game-creator-shell")
|
||
.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
);
|
||
}
|
||
}
|
||
default_paths.extend(local_paths);
|
||
default_paths
|
||
}
|
||
|
||
pub(crate) fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||
if !paths.iter().any(|existing| existing == &path) {
|
||
paths.push(path);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_config_file(
|
||
config: &mut GameCreatorAppConfig,
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
if let Some(content) = read_game_creator_config_file(path)? {
|
||
merge_game_creator_config_content(config, path, &content)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Only files inside the managed runtime config directory (real credentials)
|
||
/// may use the private-read channel: it hardens the owner/DACL on every read.
|
||
/// Repository-adjacent fallback templates and overlay files are shared inputs
|
||
/// that can be git-tracked; privatizing one on read silently locks the
|
||
/// worktree template to whichever account happened to run the dev CLI, so
|
||
/// they must go through the non-mutating snapshot channel instead.
|
||
pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool {
|
||
game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory))
|
||
}
|
||
|
||
fn read_game_creator_snapshot_file_to_string(
|
||
path: &Path,
|
||
label: &str,
|
||
max_bytes: u64,
|
||
) -> Result<String, String> {
|
||
let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?;
|
||
if metadata.len() > max_bytes {
|
||
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
|
||
}
|
||
let mut content = String::with_capacity(metadata.len() as usize);
|
||
file.read_to_string(&mut content)
|
||
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
|
||
let final_metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
|
||
if final_metadata.len() != metadata.len() {
|
||
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
|
||
}
|
||
Ok(content)
|
||
}
|
||
|
||
pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
|
||
let backup_path = game_creator_config_backup_path(path);
|
||
let path_exists = validate_game_creator_config_file_entry(path)?;
|
||
let read_path = if path_exists {
|
||
path
|
||
} else if validate_game_creator_config_file_entry(&backup_path)? {
|
||
backup_path.as_path()
|
||
} else {
|
||
return Ok(None);
|
||
};
|
||
let content = if game_creator_config_path_is_runtime_managed(read_path) {
|
||
read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||
} else {
|
||
read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||
};
|
||
Ok(Some(content))
|
||
}
|
||
|
||
fn merge_game_creator_config_content(
|
||
config: &mut GameCreatorAppConfig,
|
||
path: &Path,
|
||
content: &str,
|
||
) -> Result<(), String> {
|
||
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(content)
|
||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
|
||
if let Some(agent_mode) = file_config.agent_mode {
|
||
config.agent_mode = agent_mode;
|
||
}
|
||
if let Some(llm) = file_config.llm {
|
||
merge_game_creator_llm_config(&mut config.llm, llm);
|
||
}
|
||
if let Some(agent_llm) = file_config.agent_llm {
|
||
for (agent_id, patch) in agent_llm {
|
||
let entry = config.agent_llm.entry(agent_id).or_default();
|
||
merge_game_creator_llm_patch(entry, patch);
|
||
}
|
||
}
|
||
if let Some(editor_api) = file_config.editor_api {
|
||
merge_game_creator_editor_api_config(&mut config.editor_api, editor_api);
|
||
}
|
||
if let Some(selected_model_id) = file_config.selected_model_id {
|
||
config.selected_model_id = selected_model_id;
|
||
}
|
||
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
|
||
config.selected_model_is_default = selected_model_is_default;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn game_creator_config_backup_path(path: &Path) -> PathBuf {
|
||
path.with_file_name(format!(
|
||
".{}.previous",
|
||
path.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
))
|
||
}
|
||
|
||
pub(crate) fn write_game_creator_config_batch(writes: &[(PathBuf, String)]) -> Result<(), String> {
|
||
if writes.len() == 1 {
|
||
return write_game_creator_config_atomically(&writes[0].0, &writes[0].1);
|
||
}
|
||
let originals = writes
|
||
.iter()
|
||
.map(|(path, _)| read_game_creator_config_file(path))
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
for (index, (path, content)) in writes.iter().enumerate() {
|
||
if let Err(mut error) = write_game_creator_config_atomically(path, content) {
|
||
// 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。
|
||
for rollback_index in (0..=index).rev() {
|
||
let path = &writes[rollback_index].0;
|
||
let original = &originals[rollback_index];
|
||
if read_game_creator_config_file(path).ok().as_ref() == Some(original) {
|
||
continue;
|
||
}
|
||
let restored = match original {
|
||
Some(content) => write_game_creator_config_atomically(path, content),
|
||
None => fs::remove_file(path)
|
||
.or_else(|error| {
|
||
if error.kind() == std::io::ErrorKind::NotFound {
|
||
Ok(())
|
||
} else {
|
||
Err(error)
|
||
}
|
||
})
|
||
.map_err(|error| error.to_string()),
|
||
};
|
||
if let Err(restore_error) = restored {
|
||
error.push_str(&format!(
|
||
";恢复配置失败:{}: {restore_error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
return Err(error);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn write_game_creator_config_atomically(
|
||
path: &Path,
|
||
content: &str,
|
||
) -> Result<(), String> {
|
||
#[cfg(windows)]
|
||
validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?;
|
||
#[cfg(not(windows))]
|
||
validate_game_creator_private_path_ancestors(path, "客户端配置文件")?;
|
||
let parent = path
|
||
.parent()
|
||
.ok_or_else(|| "客户端配置缺少父目录".to_string())?;
|
||
ensure_game_creator_private_directory_tree(parent, "客户端配置目录")
|
||
.map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?;
|
||
// Never replace a symlink or another non-regular object. A regular
|
||
// existing config is repaired/tightened before it can be moved to the
|
||
// recoverable backup below.
|
||
let _initial_path_exists = validate_game_creator_config_file_entry(path)?;
|
||
let temp_path = path.with_file_name(format!(
|
||
".{}.tmp.{}.{}",
|
||
path.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
std::process::id(),
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
));
|
||
let mut options = fs::OpenOptions::new();
|
||
options.create_new(true).write(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.mode(0o600);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options.open(&temp_path).map_err(|error| {
|
||
format!(
|
||
"创建客户端配置临时文件失败:{}: {error}",
|
||
temp_path.display()
|
||
)
|
||
})?;
|
||
if let Err(error) =
|
||
harden_new_game_creator_private_path(&temp_path, false, "客户端配置临时文件")
|
||
{
|
||
drop(file);
|
||
let _ = fs::remove_file(&temp_path);
|
||
return Err(error);
|
||
}
|
||
let write_result = file
|
||
.write_all(content.as_bytes())
|
||
.and_then(|_| file.sync_all());
|
||
drop(file);
|
||
if let Err(error) = write_result {
|
||
let _ = fs::remove_file(&temp_path);
|
||
return Err(format!(
|
||
"写入客户端配置临时文件失败:{}: {error}",
|
||
temp_path.display()
|
||
));
|
||
}
|
||
|
||
let backup_path = game_creator_config_backup_path(path);
|
||
// Re-check the destination immediately before changing either directory
|
||
// entry. A newly appeared regular target is moved aside; a link,
|
||
// reparse point, foreign object, or unsafe ACL is rejected by the same
|
||
// private-path policy used for reads.
|
||
let had_previous = validate_game_creator_config_file_entry(path)?;
|
||
if validate_game_creator_config_file_entry(&backup_path)? {
|
||
fs::remove_file(&backup_path).map_err(|error| {
|
||
let _ = fs::remove_file(&temp_path);
|
||
format!(
|
||
"清理旧客户端配置备份失败:{}: {error}",
|
||
backup_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
if had_previous {
|
||
if let Err(error) = fs::rename(path, &backup_path) {
|
||
let _ = fs::remove_file(&temp_path);
|
||
return Err(format!(
|
||
"准备替换客户端配置失败:{} -> {}: {error}",
|
||
path.display(),
|
||
backup_path.display()
|
||
));
|
||
}
|
||
}
|
||
match fs::rename(&temp_path, path) {
|
||
Ok(()) => {
|
||
let _ = fs::remove_file(&backup_path);
|
||
#[cfg(windows)]
|
||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||
path, false, true,
|
||
)?;
|
||
Ok(())
|
||
}
|
||
Err(error) => {
|
||
let restore_error = if had_previous {
|
||
fs::rename(&backup_path, path).err()
|
||
} else {
|
||
None
|
||
};
|
||
let _ = fs::remove_file(&temp_path);
|
||
let restore_detail = restore_error
|
||
.map(|error| format!(";恢复旧配置失败:{error}"))
|
||
.unwrap_or_default();
|
||
Err(format!(
|
||
"替换客户端配置失败:{} -> {}: {error}{restore_detail}",
|
||
temp_path.display(),
|
||
path.display()
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_llm_config(
|
||
config: &mut GameCreatorLlmConfig,
|
||
patch: GameCreatorLlmConfigFile,
|
||
) {
|
||
if let Some(value) = patch.custom_enabled {
|
||
config.custom_enabled = value;
|
||
}
|
||
if let Some(value) = patch.visible_models {
|
||
config.visible_models = value;
|
||
}
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = value;
|
||
}
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = value;
|
||
}
|
||
if let Some(value) = patch.model {
|
||
config.model = value;
|
||
}
|
||
if let Some(value) = patch.api_kind {
|
||
config.api_kind = value;
|
||
}
|
||
if let Some(value) = patch.reasoning_effort {
|
||
config.reasoning_effort = value;
|
||
}
|
||
if let Some(value) = patch.stream {
|
||
config.stream = value;
|
||
}
|
||
if let Some(value) = patch.web_search_enabled {
|
||
config.web_search_enabled = value;
|
||
}
|
||
if let Some(value) = patch.context_window_tokens {
|
||
config.context_window_tokens = value;
|
||
}
|
||
if let Some(value) = patch.auto_compact_token_limit {
|
||
config.auto_compact_token_limit = value;
|
||
}
|
||
if let Some(value) = patch.tool_output_token_limit {
|
||
config.tool_output_token_limit = value;
|
||
}
|
||
if let Some(value) = patch.request_timeout_ms {
|
||
config.request_timeout_ms = value;
|
||
}
|
||
if let Some(value) = patch.max_retries {
|
||
config.max_retries = value;
|
||
}
|
||
if let Some(value) = patch.retry_backoff_ms {
|
||
config.retry_backoff_ms = value;
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_llm_patch(
|
||
config: &mut GameCreatorLlmConfigFile,
|
||
patch: GameCreatorLlmConfigFile,
|
||
) {
|
||
if let Some(value) = patch.custom_enabled {
|
||
config.custom_enabled = Some(value);
|
||
}
|
||
if let Some(value) = patch.visible_models {
|
||
config.visible_models = Some(value);
|
||
}
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = Some(value);
|
||
}
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = Some(value);
|
||
}
|
||
if let Some(value) = patch.model {
|
||
config.model = Some(value);
|
||
}
|
||
if let Some(value) = patch.api_kind {
|
||
config.api_kind = Some(value);
|
||
}
|
||
if let Some(value) = patch.reasoning_effort {
|
||
config.reasoning_effort = Some(value);
|
||
}
|
||
if let Some(value) = patch.stream {
|
||
config.stream = Some(value);
|
||
}
|
||
if let Some(value) = patch.web_search_enabled {
|
||
config.web_search_enabled = Some(value);
|
||
}
|
||
if let Some(value) = patch.context_window_tokens {
|
||
config.context_window_tokens = Some(value);
|
||
}
|
||
if let Some(value) = patch.auto_compact_token_limit {
|
||
config.auto_compact_token_limit = Some(value);
|
||
}
|
||
if let Some(value) = patch.tool_output_token_limit {
|
||
config.tool_output_token_limit = Some(value);
|
||
}
|
||
if let Some(value) = patch.request_timeout_ms {
|
||
config.request_timeout_ms = Some(value);
|
||
}
|
||
if let Some(value) = patch.max_retries {
|
||
config.max_retries = Some(value);
|
||
}
|
||
if let Some(value) = patch.retry_backoff_ms {
|
||
config.retry_backoff_ms = Some(value);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn resolve_game_creator_llm_config_for_agent(
|
||
config: &GameCreatorAppConfig,
|
||
agent_id: &str,
|
||
) -> GameCreatorLlmConfig {
|
||
let mut llm = config.llm.clone();
|
||
if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) {
|
||
llm.reasoning_effort = reasoning_effort.to_string();
|
||
}
|
||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||
if let Some(patch) = config
|
||
.agent_llm
|
||
.get(GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID)
|
||
{
|
||
merge_game_creator_llm_config(&mut llm, patch.clone());
|
||
}
|
||
}
|
||
if let Some(patch) = config.agent_llm.get(agent_id) {
|
||
merge_game_creator_llm_config(&mut llm, patch.clone());
|
||
}
|
||
llm
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_editor_api_config(
|
||
config: &mut GameCreatorEditorApiConfig,
|
||
patch: GameCreatorEditorApiConfigFile,
|
||
) {
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = value;
|
||
}
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = value;
|
||
}
|
||
}
|
||
|
||
pub(crate) fn trim_config_string(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
fn custom_llm_enabled_at_config_path(path: &Path) -> Result<bool, String> {
|
||
let parent = path.parent().ok_or("客户端配置缺少父目录")?;
|
||
let mut enabled = false;
|
||
for name in [
|
||
GAME_CREATOR_CONFIG_FILE_NAME,
|
||
GAME_CREATOR_LOCAL_CONFIG_FILE_NAME,
|
||
] {
|
||
if let Some(content) = read_game_creator_config_file(&parent.join(name))? {
|
||
let file: GameCreatorAppConfigFile =
|
||
serde_json::from_str(&content).map_err(|_| "解析客户端配置失败".to_string())?;
|
||
if let Some(value) = file.llm.and_then(|llm| llm.custom_enabled) {
|
||
enabled = value;
|
||
}
|
||
}
|
||
}
|
||
Ok(enabled)
|
||
}
|
||
|
||
pub(crate) fn validate_custom_llm_connection(
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<url::Url, String> {
|
||
if llm.api_key.trim().is_empty() {
|
||
return Err("请填写自定义 LLM API Key".to_string());
|
||
}
|
||
let url =
|
||
url::Url::parse(llm.base_url.trim()).map_err(|_| "自定义 LLM API 地址无效".to_string())?;
|
||
if !matches!(url.scheme(), "http" | "https")
|
||
|| url.host_str().is_none()
|
||
|| !url.username().is_empty()
|
||
|| url.password().is_some()
|
||
|| url.query().is_some()
|
||
|| url.fragment().is_some()
|
||
{
|
||
return Err("请填写不含凭据、查询参数和片段的 HTTP(S) API 根地址".to_string());
|
||
}
|
||
Ok(url)
|
||
}
|
||
|
||
pub(crate) fn normalize_custom_llm_model_ids(ids: &[String]) -> Result<Vec<String>, String> {
|
||
if ids.len() > 4096 {
|
||
return Err("模型列表超过 4096 项上限".to_string());
|
||
}
|
||
let mut result = Vec::new();
|
||
let mut seen = std::collections::HashSet::new();
|
||
for id in ids {
|
||
let id = id.trim();
|
||
if id.is_empty()
|
||
|| id.len() > 256
|
||
|| id.chars().any(|c| c.is_control() || c.is_whitespace())
|
||
{
|
||
return Err("模型标识为空、包含空白或超过 256 字节".to_string());
|
||
}
|
||
if seen.insert(id.to_string()) {
|
||
result.push(id.to_string());
|
||
}
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
fn apply_custom_llm_model_selection(config: &mut GameCreatorAppConfig) {
|
||
if !config.llm.custom_enabled {
|
||
return;
|
||
}
|
||
if config.selected_model_is_default
|
||
|| !config
|
||
.llm
|
||
.visible_models
|
||
.contains(&config.selected_model_id)
|
||
{
|
||
config.selected_model_id = config
|
||
.llm
|
||
.visible_models
|
||
.first()
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
config.selected_model_is_default = true;
|
||
}
|
||
config.llm.model = config.selected_model_id.clone();
|
||
}
|
||
|
||
pub(crate) async fn fetch_custom_llm_models(
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<Vec<String>, String> {
|
||
let mut url = validate_custom_llm_connection(llm)?;
|
||
url.set_path(&format!("{}/models", url.path().trim_end_matches('/')));
|
||
let client = reqwest::Client::builder()
|
||
.redirect(reqwest::redirect::Policy::none())
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
.build()
|
||
.map_err(|_| "初始化模型列表请求失败".to_string())?;
|
||
let mut response = client
|
||
.get(url)
|
||
.bearer_auth(llm.api_key.trim())
|
||
.send()
|
||
.await
|
||
.map_err(|error| {
|
||
if error.is_timeout() {
|
||
"模型列表请求超时,请重试".to_string()
|
||
} else {
|
||
"无法连接模型端点,请检查 API 地址和网络".to_string()
|
||
}
|
||
})?;
|
||
if !response.status().is_success() {
|
||
return Err(format!(
|
||
"模型列表读取失败(HTTP {})",
|
||
response.status().as_u16()
|
||
));
|
||
}
|
||
let mut body = Vec::new();
|
||
while let Some(chunk) = response
|
||
.chunk()
|
||
.await
|
||
.map_err(|_| "读取模型列表响应失败或超时".to_string())?
|
||
{
|
||
if body.len() + chunk.len() > 1024 * 1024 {
|
||
return Err("模型列表响应超过 1 MiB 上限".to_string());
|
||
}
|
||
body.extend_from_slice(&chunk);
|
||
}
|
||
#[derive(Deserialize)]
|
||
struct Model {
|
||
id: String,
|
||
}
|
||
#[derive(Deserialize)]
|
||
struct Models {
|
||
data: Vec<Model>,
|
||
}
|
||
let models: Models = serde_json::from_slice(&body)
|
||
.map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?;
|
||
let ids = models
|
||
.data
|
||
.into_iter()
|
||
.map(|model| model.id)
|
||
.collect::<Vec<_>>();
|
||
let mut ids = normalize_custom_llm_model_ids(&ids)?;
|
||
ids.sort();
|
||
Ok(ids)
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_app_config(
|
||
mut config: GameCreatorAppConfig,
|
||
) -> Result<GameCreatorAppConfig, String> {
|
||
if config.schema_version != GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"客户端配置 schemaVersion 不受支持:{}",
|
||
config.schema_version
|
||
));
|
||
}
|
||
if game_creator_official_llm_route_locked() {
|
||
lock_game_creator_app_config_to_official_route(&mut config);
|
||
}
|
||
config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?;
|
||
if config.llm.custom_enabled {
|
||
validate_custom_llm_connection(&config.llm)?;
|
||
config.llm.visible_models = normalize_custom_llm_model_ids(&config.llm.visible_models)?;
|
||
if config.llm.visible_models.is_empty() {
|
||
return Err("请至少勾选一个要显示的模型".to_string());
|
||
}
|
||
if config.llm.api_kind != "openai_responses" {
|
||
return Err("自定义 LLM 需要支持 OpenAI Responses 协议".to_string());
|
||
}
|
||
apply_custom_llm_model_selection(&mut config);
|
||
}
|
||
config.llm.api_key = config.llm.api_key.trim().to_string();
|
||
config.llm.base_url =
|
||
trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?;
|
||
config.llm.model =
|
||
trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?;
|
||
config.llm.api_kind =
|
||
game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?);
|
||
validate_game_creator_llm_web_search_config(&config.llm, "llm")?;
|
||
config.llm.reasoning_effort = game_creator_llm_reasoning_effort_name(
|
||
&config.llm.reasoning_effort,
|
||
"llm.reasoningEffort",
|
||
)?;
|
||
validate_game_creator_llm_timing_config(&config.llm, "llm")?;
|
||
let mut agent_llm = BTreeMap::new();
|
||
for (agent_id, patch) in config.agent_llm {
|
||
let agent_id = match trim_config_string(&agent_id) {
|
||
Some(value) => value,
|
||
None => continue,
|
||
};
|
||
let patch = normalize_game_creator_llm_patch_config(&agent_id, patch)?;
|
||
if !is_empty_game_creator_llm_patch(&patch) {
|
||
agent_llm.insert(agent_id, patch);
|
||
}
|
||
}
|
||
config.agent_llm = agent_llm;
|
||
for agent_id in config.agent_llm.keys() {
|
||
let llm = resolve_game_creator_llm_config_for_agent(&config, agent_id);
|
||
validate_game_creator_llm_web_search_config(&llm, &format!("agentLlm.{agent_id}"))?;
|
||
validate_game_creator_llm_timing_config(&llm, &format!("agentLlm.{agent_id}"))?;
|
||
}
|
||
config.editor_api.base_url = trim_config_string(&config.editor_api.base_url)
|
||
.ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?;
|
||
config.editor_api.api_key = config.editor_api.api_key.trim().to_string();
|
||
Ok(config)
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, String> {
|
||
match value.trim() {
|
||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
|
||
}
|
||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
|
||
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
|
||
value => Err(format!(
|
||
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_llm_patch_config(
|
||
agent_id: &str,
|
||
mut patch: GameCreatorLlmConfigFile,
|
||
) -> Result<GameCreatorLlmConfigFile, String> {
|
||
patch.api_key = patch.api_key.and_then(|value| trim_config_string(&value));
|
||
patch.base_url = patch.base_url.and_then(|value| trim_config_string(&value));
|
||
patch.model = patch.model.and_then(|value| trim_config_string(&value));
|
||
patch.api_kind = match patch.api_kind {
|
||
Some(value) => Some(game_creator_llm_api_kind_name(
|
||
parse_game_creator_llm_api_kind(&value)
|
||
.map_err(|error| format!("配置项 agentLlm.{agent_id}.apiKind 无效:{error}"))?,
|
||
)),
|
||
None => None,
|
||
};
|
||
patch.reasoning_effort = match patch.reasoning_effort {
|
||
Some(value) => Some(game_creator_llm_reasoning_effort_name(
|
||
&value,
|
||
&format!("agentLlm.{agent_id}.reasoningEffort"),
|
||
)?),
|
||
None => None,
|
||
};
|
||
if patch
|
||
.request_timeout_ms
|
||
.is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
|
||
{
|
||
return Err(format!(
|
||
"配置项 agentLlm.{agent_id}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
|
||
));
|
||
}
|
||
if patch.retry_backoff_ms.is_some_and(|value| value == 0) {
|
||
return Err(format!(
|
||
"配置项 agentLlm.{agent_id}.retryBackoffMs 必须大于 0"
|
||
));
|
||
}
|
||
for (field, value) in [
|
||
("contextWindowTokens", patch.context_window_tokens),
|
||
("autoCompactTokenLimit", patch.auto_compact_token_limit),
|
||
("toolOutputTokenLimit", patch.tool_output_token_limit),
|
||
] {
|
||
if value.is_some_and(|value| value == 0) {
|
||
return Err(format!("配置项 agentLlm.{agent_id}.{field} 必须大于 0"));
|
||
}
|
||
}
|
||
Ok(patch)
|
||
}
|
||
|
||
pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool {
|
||
patch.custom_enabled.is_none()
|
||
&& patch.visible_models.is_none()
|
||
&& patch.api_key.is_none()
|
||
&& patch.base_url.is_none()
|
||
&& patch.model.is_none()
|
||
&& patch.api_kind.is_none()
|
||
&& patch.reasoning_effort.is_none()
|
||
&& patch.stream.is_none()
|
||
&& patch.web_search_enabled.is_none()
|
||
&& patch.context_window_tokens.is_none()
|
||
&& patch.auto_compact_token_limit.is_none()
|
||
&& patch.tool_output_token_limit.is_none()
|
||
&& patch.request_timeout_ms.is_none()
|
||
&& patch.max_retries.is_none()
|
||
&& patch.retry_backoff_ms.is_none()
|
||
}
|
||
|
||
pub(crate) fn llm_api_key_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM 未配置:请在 {} 的 {config_path}.apiKey 中设置 API Key",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn llm_base_url_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM base_url 未配置:请在 {} 的 {config_path}.baseUrl 中设置",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn llm_model_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM model 未配置:请在 {} 的 {config_path}.model 中设置",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn game_creator_config_file_label(file_name: &str) -> String {
|
||
game_creator_runtime_config_dir()
|
||
.map(|directory| directory.join(file_name).display().to_string())
|
||
.unwrap_or_else(|| file_name.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod anthropic_strict_capability_tests {
|
||
use super::*;
|
||
|
||
fn anthropic_config(base_url: &str, model: &str) -> GameCreatorLlmConfig {
|
||
GameCreatorLlmConfig {
|
||
api_key: "test-key".to_string(),
|
||
base_url: base_url.to_string(),
|
||
model: model.to_string(),
|
||
api_kind: "anthropic".to_string(),
|
||
web_search_enabled: false,
|
||
..GameCreatorLlmConfig::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn official_supported_claude_model_opts_in_to_anthropic_strict_tools() {
|
||
for model in [
|
||
"claude-sonnet-4-5-20250929",
|
||
"claude-opus-4-6",
|
||
"claude-haiku-5",
|
||
] {
|
||
let config = build_game_creator_platform_llm_config(
|
||
&anthropic_config("https://api.anthropic.com", model),
|
||
"llm",
|
||
)
|
||
.expect("supported official Anthropic config");
|
||
assert!(config.anthropic_strict_tool_support(), "model={model}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn old_models_and_compatible_or_lookalike_endpoints_keep_strict_disabled() {
|
||
for (base_url, model) in [
|
||
("https://api.anthropic.com", "claude-3-5-sonnet-latest"),
|
||
("https://api.anthropic.com", "claude-sonnet-latest"),
|
||
("https://minimax.example.com", "claude-sonnet-4-5"),
|
||
("https://api.anthropic.com.example.com", "claude-sonnet-4-5"),
|
||
("http://api.anthropic.com", "claude-sonnet-4-5"),
|
||
] {
|
||
let config =
|
||
build_game_creator_platform_llm_config(&anthropic_config(base_url, model), "llm")
|
||
.expect("non-capable Anthropic config remains usable without strict");
|
||
assert!(
|
||
!config.anthropic_strict_tool_support(),
|
||
"base_url={base_url}, model={model}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod private_file_write_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn private_file_write_installs_via_hardened_sibling_and_replaces_regular_target() {
|
||
let root = tempfile::tempdir().expect("create private file fixture");
|
||
let parent = root.path().join("private");
|
||
let path = parent.join("state.json");
|
||
|
||
write_game_creator_private_file(&path, b"first\n", "测试私有文件")
|
||
.expect("install first private file");
|
||
assert_eq!(
|
||
fs::read(&path).expect("read first private file"),
|
||
b"first\n"
|
||
);
|
||
|
||
write_game_creator_private_file(&path, b"second\n", "测试私有文件")
|
||
.expect("replace private file");
|
||
assert_eq!(
|
||
fs::read(&path).expect("read replaced private file"),
|
||
b"second\n"
|
||
);
|
||
|
||
let leftovers = fs::read_dir(&parent)
|
||
.expect("read private file directory")
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||
.filter(|name| name.contains(".tmp-") || name.contains(".previous-"))
|
||
.collect::<Vec<_>>();
|
||
assert!(
|
||
leftovers.is_empty(),
|
||
"temporary/backup residue: {leftovers:?}"
|
||
);
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
let metadata = fs::symlink_metadata(&path).expect("metadata");
|
||
assert_eq!(metadata.mode() & 0o777, 0o600);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn private_file_append_creates_and_reuses_hardened_target() {
|
||
let root = tempfile::tempdir().expect("create append fixture");
|
||
let path = root.path().join("private").join("journal.log");
|
||
|
||
append_game_creator_private_file(&path, b"first\n", "测试追加文件")
|
||
.expect("append first record");
|
||
append_game_creator_private_file(&path, b"second\n", "测试追加文件")
|
||
.expect("append second record");
|
||
assert_eq!(
|
||
fs::read(&path).expect("read append file"),
|
||
b"first\nsecond\n"
|
||
);
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
let metadata = fs::symlink_metadata(&path).expect("append metadata");
|
||
assert_eq!(metadata.nlink(), 1);
|
||
assert_eq!(metadata.mode() & 0o777, 0o600);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod custom_llm_tests {
|
||
use super::*;
|
||
use std::io::{Read, Write};
|
||
|
||
fn custom_llm() -> GameCreatorLlmConfig {
|
||
GameCreatorLlmConfig {
|
||
custom_enabled: true,
|
||
api_key: "custom-fixture-key".into(),
|
||
base_url: "https://provider.example/v1".into(),
|
||
model: "vendor/model.v1:latest".into(),
|
||
visible_models: vec!["vendor/model.v1:latest".into(), "second.model".into()],
|
||
..GameCreatorLlmConfig::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_llm_defaults_closed_and_explicit_config_survives_scrub() {
|
||
assert!(!GameCreatorLlmConfig::default().custom_enabled);
|
||
let mut file: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
||
"llm": {"customEnabled": true, "apiKey": "fixture", "baseUrl": "https://custom.example/v1", "visibleModels": ["vendor/a.v1"]}
|
||
})).unwrap();
|
||
assert!(!scrub_locked_game_creator_config_file(&mut file));
|
||
let mut config = GameCreatorAppConfig::default();
|
||
merge_game_creator_llm_config(&mut config.llm, file.llm.unwrap());
|
||
assert!(config.llm.custom_enabled);
|
||
assert_eq!(config.llm.api_key, "fixture");
|
||
assert_eq!(config.llm.visible_models, ["vendor/a.v1"]);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_llm_selection_is_allowlisted_and_removed_model_falls_back() {
|
||
let mut config = GameCreatorAppConfig {
|
||
llm: custom_llm(),
|
||
selected_model_id: "second.model".into(),
|
||
..GameCreatorAppConfig::default()
|
||
};
|
||
apply_custom_llm_model_selection(&mut config);
|
||
assert_eq!(config.llm.model, "second.model");
|
||
config.llm.visible_models.pop();
|
||
let normalized = normalize_game_creator_app_config(config).unwrap();
|
||
assert_eq!(normalized.llm.model, "vendor/model.v1:latest");
|
||
assert_eq!(normalized.selected_model_id, normalized.llm.model);
|
||
assert!(normalized.selected_model_is_default);
|
||
assert!(game_creator_codex_app_server_llm_route_error(
|
||
"codex_app_server",
|
||
&normalized.llm,
|
||
"llm"
|
||
)
|
||
.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_llm_missing_connection_or_models_is_rejected_without_official_fallback() {
|
||
for field in ["key", "models", "url"] {
|
||
let mut config = GameCreatorAppConfig {
|
||
llm: custom_llm(),
|
||
..GameCreatorAppConfig::default()
|
||
};
|
||
match field {
|
||
"key" => config.llm.api_key.clear(),
|
||
"models" => config.llm.visible_models.clear(),
|
||
_ => config.llm.base_url = "file:///private".into(),
|
||
}
|
||
assert!(
|
||
normalize_game_creator_app_config(config).is_err(),
|
||
"{field}"
|
||
);
|
||
}
|
||
let mut config = custom_llm();
|
||
config.api_key.clear();
|
||
assert!(build_game_creator_platform_llm_config(&config, "llm").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_llm_migration_uses_merged_overlay_switch() {
|
||
let root = tempfile::tempdir().unwrap();
|
||
let primary = root.path().join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||
let overlay = root.path().join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
||
std::fs::write(
|
||
&primary,
|
||
r#"{"llm":{"customEnabled":false,"apiKey":"fixture"}}"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(&overlay, r#"{"llm":{"customEnabled":true}}"#).unwrap();
|
||
assert!(custom_llm_enabled_at_config_path(&primary).unwrap());
|
||
std::fs::write(&overlay, r#"{"llm":{"customEnabled":false}}"#).unwrap();
|
||
assert!(!custom_llm_enabled_at_config_path(&primary).unwrap());
|
||
}
|
||
|
||
fn model_server(status: &str, body: &str) -> (String, std::thread::JoinHandle<String>) {
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||
let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||
let handle = std::thread::spawn(move || {
|
||
let (mut socket, _) = listener.accept().unwrap();
|
||
socket
|
||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||
.unwrap();
|
||
let mut request = Vec::new();
|
||
let mut buf = [0; 1024];
|
||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||
let count = socket.read(&mut buf).unwrap();
|
||
if count == 0 {
|
||
break;
|
||
}
|
||
request.extend_from_slice(&buf[..count]);
|
||
}
|
||
let _ = socket.write_all(response.as_bytes());
|
||
String::from_utf8(request).unwrap()
|
||
});
|
||
(url, handle)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn custom_llm_discovers_models_directly_with_custom_bearer_and_deduplicates() {
|
||
let (url, server) = model_server(
|
||
"200 OK",
|
||
r#"{"data":[{"id":"vendor/model.v1:latest"},{"id":"second.model"},{"id":"second.model"}]}"#,
|
||
);
|
||
let mut llm = custom_llm();
|
||
llm.base_url = url;
|
||
assert_eq!(
|
||
fetch_custom_llm_models(&llm).await.unwrap(),
|
||
["second.model", "vendor/model.v1:latest"]
|
||
);
|
||
let request = server.join().unwrap();
|
||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||
assert!(request
|
||
.to_ascii_lowercase()
|
||
.contains("authorization: bearer custom-fixture-key"));
|
||
assert!(!request.contains("/api/llm"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn custom_llm_discovery_reports_safe_errors_and_bounds_response() {
|
||
for (status, body, expected) in [
|
||
(
|
||
"401 Unauthorized",
|
||
"private-upstream-secret".to_string(),
|
||
"HTTP 401",
|
||
),
|
||
(
|
||
"302 Found",
|
||
"private-upstream-secret".to_string(),
|
||
"HTTP 302",
|
||
),
|
||
("200 OK", "not-json-private-secret".to_string(), "data[].id"),
|
||
("200 OK", "x".repeat(1024 * 1024 + 1), "1 MiB"),
|
||
] {
|
||
let (url, server) = model_server(status, &body);
|
||
let mut llm = custom_llm();
|
||
llm.base_url = url;
|
||
let error = fetch_custom_llm_models(&llm).await.unwrap_err();
|
||
assert!(error.contains(expected), "{error}");
|
||
assert!(!error.contains("secret"));
|
||
server.join().unwrap();
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn custom_llm_discovery_times_out_when_response_body_stalls() {
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let mut llm = custom_llm();
|
||
llm.base_url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||
let server = std::thread::spawn(move || {
|
||
let (mut socket, _) = listener.accept().unwrap();
|
||
let mut request = [0; 4096];
|
||
socket.read(&mut request).unwrap();
|
||
socket
|
||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
|
||
.unwrap();
|
||
std::thread::sleep(std::time::Duration::from_secs(16));
|
||
});
|
||
let start = std::time::Instant::now();
|
||
let error = fetch_custom_llm_models(&llm).await.unwrap_err();
|
||
assert!(error.contains("超时"), "{error}");
|
||
assert!(start.elapsed() < std::time::Duration::from_secs(16));
|
||
server.join().unwrap();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod private_path_elevation_policy_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn automatic_elevation_covers_all_regular_project_descendants() {
|
||
let root = tempfile::tempdir().expect("create path policy fixture");
|
||
let external_file = root.path().join("game").join("index.html");
|
||
let nested_project_file = root.path().join("assets").join("sprites").join("hero.png");
|
||
let managed_agent_file = root
|
||
.path()
|
||
.join(".agent")
|
||
.join("runtime")
|
||
.join("state.json");
|
||
fs::create_dir_all(managed_agent_file.parent().expect("agent parent"))
|
||
.expect("create managed agent fixture");
|
||
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
|
||
let traversal_path = root
|
||
.path()
|
||
.join(".agent")
|
||
.join("..")
|
||
.join("Windows")
|
||
.join("System32");
|
||
|
||
assert!(game_creator_private_path_allows_auto_elevation(
|
||
&external_file
|
||
));
|
||
assert!(game_creator_private_path_allows_auto_elevation(
|
||
&nested_project_file
|
||
));
|
||
assert!(game_creator_private_path_allows_auto_elevation(
|
||
&managed_agent_file
|
||
));
|
||
assert!(!game_creator_private_path_allows_auto_elevation(
|
||
&traversal_path
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn project_marker_does_not_authorize_unrelated_or_nested_agent_paths() {
|
||
let root = tempfile::tempdir().expect("create path policy fixture");
|
||
fs::create_dir_all(root.path().join(".agent")).expect("create agent directory");
|
||
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
|
||
|
||
let unrelated = tempfile::tempdir().expect("create unrelated fixture");
|
||
let unrelated_file = unrelated.path().join("game/index.html");
|
||
let nested_agent = root.path().join("game").join(".agent").join("state.json");
|
||
|
||
assert!(!game_creator_private_path_allows_auto_elevation(
|
||
&unrelated_file
|
||
));
|
||
assert!(!game_creator_private_path_allows_auto_elevation(
|
||
&nested_agent
|
||
));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn explicit_user_selection_allows_repair_on_user_selected_non_system_path() {
|
||
let profile = std::env::var_os("USERPROFILE")
|
||
.or_else(|| std::env::var_os("HOME"))
|
||
.map(PathBuf::from)
|
||
.expect("current user profile");
|
||
let windir = std::env::var_os("WINDIR")
|
||
.map(PathBuf::from)
|
||
.expect("WINDIR");
|
||
let program_files = std::env::var_os("ProgramFiles")
|
||
.map(PathBuf::from)
|
||
.expect("ProgramFiles");
|
||
let system_drive = std::env::var_os("SystemDrive")
|
||
.map(PathBuf::from)
|
||
.expect("SystemDrive");
|
||
let selected = profile.join("Documents").join("fixture.png");
|
||
let external_drive = PathBuf::from(r"D:\Genarrative\fixture.png");
|
||
let system_path = windir.join("System32").join("fixture.png");
|
||
let program_files_path = program_files.join("Genarrative").join("fixture.png");
|
||
let drive_root = PathBuf::from(format!(
|
||
"{}\\",
|
||
system_drive.to_string_lossy().trim_end_matches(['\\', '/'])
|
||
));
|
||
let unc_root = PathBuf::from(r"\\server\share\");
|
||
let traversal_path = PathBuf::from(r"C:\Users\test\..\Windows\fixture.png");
|
||
|
||
assert!(game_creator_user_selected_path_allows_auto_elevation(
|
||
&selected
|
||
));
|
||
assert!(game_creator_user_selected_path_allows_auto_elevation(
|
||
&external_drive
|
||
));
|
||
assert!(!game_creator_user_selected_path_allows_auto_elevation(
|
||
&system_path
|
||
));
|
||
assert!(!game_creator_user_selected_path_allows_auto_elevation(
|
||
&program_files_path
|
||
));
|
||
assert!(!game_creator_user_selected_path_allows_auto_elevation(
|
||
&drive_root
|
||
));
|
||
assert!(!game_creator_user_selected_path_allows_auto_elevation(
|
||
&unc_root
|
||
));
|
||
assert!(!game_creator_user_selected_path_allows_auto_elevation(
|
||
&traversal_path
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn arbitrary_agent_directory_without_project_marker_cannot_trigger_elevation() {
|
||
let root = tempfile::tempdir().expect("create unverified agent fixture");
|
||
let path = root
|
||
.path()
|
||
.join(".agent")
|
||
.join("runtime")
|
||
.join("state.json");
|
||
fs::create_dir_all(path.parent().expect("agent parent")).expect("create agent fixture");
|
||
assert!(!game_creator_private_path_allows_auto_elevation(&path));
|
||
}
|
||
|
||
#[test]
|
||
fn nested_agent_components_cannot_trigger_elevation() {
|
||
let root = tempfile::tempdir().expect("create nested agent fixture");
|
||
let path = root
|
||
.path()
|
||
.join(".agent")
|
||
.join("nested")
|
||
.join(".agent")
|
||
.join("state.json");
|
||
fs::create_dir_all(path.parent().expect("nested agent parent"))
|
||
.expect("create nested agent");
|
||
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
|
||
assert!(!game_creator_private_path_allows_auto_elevation(&path));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn explicit_project_root_entry_can_tighten_owner_correct_inherited_acl() {
|
||
let root = tempfile::tempdir().expect("create project root fixture");
|
||
assert!(prepare_game_creator_project_root_for_read(root.path(), true, "项目根").is_ok());
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn owner_flag_is_requested_only_when_owner_initialization_is_needed() {
|
||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||
assert_eq!(
|
||
windows_private_dacl_security_information(true, false) & OWNER_SECURITY_INFORMATION,
|
||
OWNER_SECURITY_INFORMATION
|
||
);
|
||
assert_eq!(
|
||
windows_private_dacl_security_information(true, true) & OWNER_SECURITY_INFORMATION,
|
||
0
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn canonicalize_access_denied_is_classified_as_acl_repair_candidate() {
|
||
let detail =
|
||
"解析客户端 AppData 配置目录失败:C:\\Temp\\agc-config: 拒绝访问。 (os error 5)";
|
||
assert!(windows_acl_error_may_need_elevation(detail));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn acl_repair_argument_list_keeps_space_containing_path_quoted() {
|
||
let path = r"C:\Users\lingh\Documents\Genarrative GameAgent\gameagent-f84a5353\.agent\project.lock";
|
||
let arguments = windows_acl_repair_argument_list(
|
||
path,
|
||
"S-1-5-21-1-2-3-1001",
|
||
"0123456789abcdef0123456789abcdef",
|
||
WindowsAclRepairScope::Managed,
|
||
);
|
||
assert_eq!(
|
||
arguments,
|
||
format!(
|
||
"\"--repair-private-acl\" \"{path}\" \"--target-user-sid\" \"S-1-5-21-1-2-3-1001\" \"--authorization\" \"0123456789abcdef0123456789abcdef\" \"--scope\" \"managed\""
|
||
)
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn custom_runtime_config_path_uses_explicit_user_selected_scope() {
|
||
let root = tempfile::tempdir().expect("create custom config fixture");
|
||
assert_eq!(
|
||
game_creator_runtime_config_repair_scope(&root.path().join("config")),
|
||
WindowsAclRepairScope::UserSelected
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
|
||
let root = std::env::var_os("LOCALAPPDATA")
|
||
.or_else(|| std::env::var_os("APPDATA"))
|
||
.map(PathBuf::from)
|
||
.expect("local appdata");
|
||
let packaged = root.join("world.genarrative.ai-game-creator");
|
||
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
|
||
|
||
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
|
||
assert_eq!(
|
||
game_creator_runtime_config_repair_scope(&verbatim),
|
||
WindowsAclRepairScope::Managed
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
|
||
let root = tempfile::tempdir().expect("create picker grant fixture");
|
||
let directory = root.path().join("selected");
|
||
let child = directory.join("nested").join("file.png");
|
||
let unapproved = root.path().join("other").join("file.png");
|
||
|
||
assert!(!user_selected_path_is_authorized(&directory, true));
|
||
assert!(!user_selected_path_is_authorized(&unapproved, false));
|
||
|
||
register_game_creator_user_selected_path(&directory, true);
|
||
assert!(user_selected_path_is_authorized(&directory, true));
|
||
assert!(user_selected_path_is_authorized(&child, false));
|
||
assert!(!user_selected_path_is_authorized(&directory, false));
|
||
assert!(!user_selected_path_is_authorized(&unapproved, false));
|
||
|
||
revoke_game_creator_user_selected_path(&directory);
|
||
assert!(!user_selected_path_is_authorized(&directory, true));
|
||
assert!(!user_selected_path_is_authorized(&child, false));
|
||
}
|
||
}
|