Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs
T
kdletters 9fe800da80
Project CI / Repository checks (push) Successful in 2m33s
Project CI / Frontend tests (push) Successful in 2m55s
Project CI / Backend tests (push) Successful in 3m58s
Project CI / Native shell tests (push) Failing after 16m33s
修复AGC Native测试认证与阻断
- 为画布与资源编辑 loopback fixture 增加 task-local External Editor 凭据

- 为平台账号语义测试安装隔离会话并修正资源编辑错误分类断言

- 为 Windows mock listener 增加有界 accept 与 blocking stream 恢复

- 同步 autonomous main-loop、completion fixture 与项目决策记录
2026-08-19 03:24:29 +08:00

1996 lines
73 KiB
Rust

use super::*;
use sha2::{Digest as _, Sha256};
use std::future::Future;
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024;
const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)";
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str =
"private-external-editor-credential-storage-preparation-failed";
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str =
"private-external-editor-credential-persistence-failed";
/// A task-scoped External Editor credential used only by the direct Codex
/// runtime. It never changes the GUI account session and deliberately keeps
/// the key private: callers may use it to make authenticated requests but may
/// not format or serialize this value for diagnostics.
#[derive(Clone)]
pub(crate) struct ExternalEditorApiCredentials {
api_base_url: String,
api_key: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PrivateExternalEditorApiKeyFile {
api_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
}
tokio::task_local! {
static EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE: ExternalEditorApiCredentials;
}
fn active_external_editor_api_credentials() -> Option<ExternalEditorApiCredentials> {
EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE
.try_with(Clone::clone)
.ok()
}
pub(crate) async fn with_external_editor_api_credentials<T>(
credentials: ExternalEditorApiCredentials,
operation: impl Future<Output = T>,
) -> T {
EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE
.scope(credentials, operation)
.await
}
#[cfg(test)]
pub(crate) fn external_editor_api_credentials_for_test(
api_base_url: String,
api_key: String,
) -> ExternalEditorApiCredentials {
ExternalEditorApiCredentials {
api_base_url,
api_key,
}
}
fn private_external_editor_api_key_directory() -> Result<PathBuf, String> {
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?;
Ok(home.join(".config").join("genarrative"))
}
fn private_external_editor_api_key_path_for_base_url(
api_base_url: &str,
) -> Result<PathBuf, String> {
let api_base_url = normalize_external_editor_api_base_url(api_base_url)?;
let fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes()));
Ok(private_external_editor_api_key_directory()?.join(format!(
"{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX}{}.json",
&fingerprint[..16]
)))
}
pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result<String, String> {
let value = value.trim().trim_end_matches('/');
let parsed =
url::Url::parse(value).map_err(|_| "陶泥儿 External Editor 地址无效".to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
{
return Err("陶泥儿 External Editor 地址必须是纯 HTTP(S) origin".to_string());
}
let host = parsed
.host_str()
.ok_or_else(|| "陶泥儿 External Editor 地址缺少 host".to_string())?;
let loopback = host == "localhost"
|| host == "127.0.0.1"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if parsed.scheme() == "http" && !loopback {
return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
}
fn normalize_external_editor_api_key(value: &str) -> Result<String, String> {
let value = value.trim();
if !value.starts_with("tnr_sk_")
|| value.len() > 1024
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err("本机陶泥儿开发者 API Key 无效,请重新登录客户端后重试".to_string());
}
Ok(value.to_string())
}
fn private_external_editor_api_credentials_from_file_at(
path: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}"));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string());
}
if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES {
return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string());
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, false)?;
let content = fs::read_to_string(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
let parsed = serde_json::from_str::<PrivateExternalEditorApiKeyFile>(&content)
.map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?;
let api_key = normalize_external_editor_api_key(&parsed.api_key)?;
let api_base_url = normalize_external_editor_api_base_url(
parsed
.base_url
.as_deref()
.unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL),
)?;
Ok(Some(ExternalEditorApiCredentials {
api_base_url,
api_key,
}))
}
fn ensure_plain_private_external_editor_directory(
path: &Path,
label: &str,
) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(false)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let created = match fs::create_dir(path) {
Ok(()) => true,
Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => {
false
}
Err(create_error) => {
return Err(format!(
"创建本机陶泥儿开发者凭据{label}失败:{create_error}"
));
}
};
let metadata = fs::symlink_metadata(path).map_err(|metadata_error| {
format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}")
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(created)
}
Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")),
}
}
/// Prepares the exact private directory before a one-time remote developer key
/// is requested. Existing directories are verified, not adopted; only the
/// directory created by this invocation may have its Windows owner initialized.
fn prepare_private_external_editor_api_credentials_parent_dir_at(
path: &Path,
) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| "本机陶泥儿开发者凭据配置缺少父目录".to_string())?;
let container = parent
.parent()
.ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?;
ensure_plain_private_external_editor_directory(container, "上级目录")?;
let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?;
#[cfg(windows)]
if parent_created {
initialize_windows_game_creator_directory_owner_for_current_user(parent)?;
} else {
secure_windows_game_creator_path_for_current_user(parent, true, false)?;
}
#[cfg(unix)]
if parent_created {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?;
}
Ok(())
}
pub(crate) fn private_external_editor_credentials_storage_preparation_failed(error: &str) -> bool {
error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE)
}
pub(crate) fn private_external_editor_credentials_persistence_failed(error: &str) -> bool {
error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE)
}
fn write_private_external_editor_api_credentials_at(
path: &Path,
credentials: &ExternalEditorApiCredentials,
) -> Result<(), String> {
prepare_private_external_editor_api_credentials_parent_dir_at(path)?;
let parent = path
.parent()
.ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少父目录".to_string())?;
let parent_metadata = fs::symlink_metadata(parent)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?;
if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string());
}
if path.exists() {
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
}
let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile {
api_key: credentials.api_key.clone(),
base_url: Some(credentials.api_base_url.clone()),
})
.map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?;
let file_name = path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少文件名".to_string())?;
let temporary = parent.join(format!(
".{file_name}.tmp.{}.{}",
std::process::id(),
unix_millis(),
));
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?;
let write_result = file
.write_all(format!("{body}\n").as_bytes())
.and_then(|_| file.sync_all());
drop(file);
if let Err(error) = write_result {
let _ = fs::remove_file(&temporary);
return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}"));
}
#[cfg(windows)]
initialize_windows_game_creator_file_owner_for_current_user(&temporary)?;
match fs::hard_link(&temporary, path) {
Ok(()) => {
let _ = fs::remove_file(&temporary);
}
Err(error) => {
let _ = fs::remove_file(&temporary);
return Err(match error.kind() {
std::io::ErrorKind::AlreadyExists => {
"本机陶泥儿开发者 Key 已被另一个进程写入,请直接重试".to_string()
}
_ => format!("安全保存本机陶泥儿开发者 Key 失败:{error}"),
});
}
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, true)?;
Ok(())
}
async fn create_private_external_editor_api_credentials_from_platform_session(
) -> Result<ExternalEditorApiCredentials, String> {
let session = current_platform_session().ok_or_else(|| {
"authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key"
.to_string()
})?;
let api_base_url = normalize_external_editor_api_base_url(&session.api_base_url)?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?;
let response = client
.post(format!("{api_base_url}/api/profile/api-keys"))
.bearer_auth(&session.access_token)
.json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME }))
.send()
.await
.map_err(|error| {
format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}")
})?;
let status = response.status();
if !status.is_success() {
return Err(match status {
reqwest::StatusCode::UNAUTHORIZED => {
"authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string()
}
reqwest::StatusCode::FORBIDDEN => {
"permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string()
}
_ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()),
});
}
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?;
let api_key = payload
.get("apiKey")
.or_else(|| payload.pointer("/data/apiKey"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "创建本机陶泥儿开发者 Key 响应缺少一次性 Key".to_string())?;
Ok(ExternalEditorApiCredentials {
api_base_url,
api_key: normalize_external_editor_api_key(api_key)?,
})
}
/// Reads the user-private External Editor key used by the direct Runtime. If
/// it does not exist, an already authenticated GUI account may create exactly
/// one key and write it to private local storage. The GUI login token is never
/// copied into the file or passed to the later isolated Runtime.
pub(crate) async fn ensure_private_external_editor_api_credentials(
) -> Result<ExternalEditorApiCredentials, String> {
let session = current_platform_session().ok_or_else(|| {
"authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key"
.to_string()
})?;
let path = private_external_editor_api_key_path_for_base_url(&session.api_base_url)?;
if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? {
return Ok(credentials);
}
prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| {
format!(
"{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据"
)
})?;
// Another local client may have completed the atomic write while this call
// prepared the directory. Re-read before creating a distinct remote key.
if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? {
return Ok(credentials);
}
let credentials =
create_private_external_editor_api_credentials_from_platform_session().await?;
if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() {
return Err(format!(
"{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试"
));
}
Ok(credentials)
}
pub(crate) fn platform_editor_uses_account_routes() -> bool {
active_external_editor_api_credentials().is_none()
&& editor_api_mode() == EditorApiMode::PlatformAccount
}
pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
active_external_editor_api_credentials().is_some()
}
pub(crate) fn upload_local_asset_at(
root: &Path,
file_name: &str,
media_type: &str,
bytes: &[u8],
) -> Result<UploadLocalAssetResult, String> {
if bytes.is_empty() {
return Err("上传文件不能为空".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let safe_name = sanitize_file_name(file_name);
let asset_id = format!("upload-{}", unix_millis());
let relative_path = format!("assets/uploads/{asset_id}-{safe_name}");
let absolute_path = root.join(&relative_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, bytes)
.map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?;
register_local_asset_entry(
root,
&relative_path,
"uploaded",
media_type,
"upload",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
}
pub(crate) fn register_local_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
source_kind: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let absolute_path = resolve_local_project_path(root, local_path)?;
let metadata = fs::metadata(&absolute_path)
.map_err(|error| format!("读取资产文件失败:{}: {error}", absolute_path.display()))?;
if !metadata.is_file() {
return Err("只能登记文件资产".to_string());
}
let id_prefix = if source_kind.is_empty() {
"generated"
} else {
source_kind
};
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
}
pub(crate) fn import_canvas_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
canvas_project_id: &str,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
prompt: Option<String>,
model: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if resource_id.is_none() && asset_object_id.is_none() {
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
}
register_local_asset_at(
root,
local_path,
kind,
media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id,
asset_object_id,
task_id,
prompt,
model,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
}
pub(crate) fn import_canvas_export_at(
root: &Path,
export_path: &Path,
canvas_project_id: &str,
) -> Result<ImportCanvasExportResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if export_path.as_os_str().is_empty() {
return Err("画板导出 ZIP 路径不能为空".to_string());
}
if !export_path.is_absolute() {
return Err("画板导出 ZIP 路径必须是绝对路径".to_string());
}
let metadata = fs::symlink_metadata(export_path)
.map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
if metadata.file_type().is_symlink() {
return Err("画板导出 ZIP 不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("画板导出路径必须是 ZIP 文件".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let source = File::open(export_path)
.map_err(|error| format!("打开画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
let mut archive =
zip::ZipArchive::new(source).map_err(|error| format!("读取画板导出 ZIP 失败:{error}"))?;
let (metadata_entry, export_metadata) = read_canvas_export_metadata_from_zip(&mut archive)?;
let zip_root_prefix = metadata_entry
.strip_suffix("metadata.json")
.unwrap_or_default()
.to_string();
let source_stem = export_path
.file_stem()
.and_then(|value| value.to_str())
.map(sanitize_file_name)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "canvas-export".to_string());
let import_relative_root = format!("assets/canvas-imports/{source_stem}-{}", unix_millis());
let copied_files = extract_canvas_export_zip_files(
root,
&mut archive,
&zip_root_prefix,
&import_relative_root,
)?;
let mut assets = Vec::new();
for layer in &export_metadata.layers {
if layer.export_error.is_some() {
continue;
}
let Some(file) = layer
.file
.as_deref()
.map(str::trim)
.filter(|file| !file.is_empty())
else {
continue;
};
let file = normalize_relative_path(file)?;
if !copied_files.contains(&file) {
continue;
}
let local_path = format!("{import_relative_root}/{file}");
let asset_object_id =
if layer.visible.object.trim().is_empty() || layer.visible.object.trim() == "-" {
format!("canvas-export:{file}")
} else {
layer.visible.object.trim().to_string()
};
let task_id = if layer.visible.task.trim().is_empty() || layer.visible.task.trim() == "-" {
None
} else {
Some(layer.visible.task.trim().to_string())
};
let model = if layer.visible.model.trim().is_empty() || layer.visible.model.trim() == "-" {
None
} else {
Some(layer.visible.model.trim().to_string())
};
assets.push(register_local_asset_entry(
root,
&local_path,
infer_canvas_export_asset_kind(layer, &file),
infer_canvas_export_media_type(&file),
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id: None,
asset_object_id: Some(asset_object_id),
task_id,
prompt: Some(layer.title.trim().to_string()).filter(|value| !value.is_empty()),
model,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?);
}
let metadata_path = format!("{import_relative_root}/metadata.json");
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.export_import",
"canvasProjectId": canvas_project_id,
"projectTitle": export_metadata.project_title,
"exportedAt": export_metadata.exported_at,
"importRoot": import_relative_root,
"metadataPath": metadata_path,
"importedCount": assets.len(),
}),
)?;
Ok(ImportCanvasExportResult {
import_root: import_relative_root,
metadata_path,
imported_count: assets.len(),
assets,
})
}
pub(crate) async fn sync_canvas_project_assets_at(
root: &Path,
canvas_project_id: &str,
api_base_url: Option<String>,
api_key: Option<String>,
) -> Result<SyncCanvasProjectAssetsResult, String> {
let canvas_project_id = canvas_project_id.trim();
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let api_base_url = resolve_canvas_sync_api_base_url(api_base_url)?;
let api_key = resolve_canvas_sync_api_key(api_key)?;
let client = reqwest::Client::new();
let project_url = format!(
"{}{}",
api_base_url,
resolve_platform_editor_api_route(&format!(
"/api/external/v1/editor/projects/{}",
percent_encode_query_component(canvas_project_id)
))
);
let project_response = client
.get(project_url)
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("读取画板项目失败:{error}"))?;
let project_status = project_response.status();
if !project_status.is_success() {
return Err(format!(
"读取画板项目失败:HTTP {}",
project_status.as_u16()
));
}
let project_payload = project_response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析画板项目失败:{error}"))?;
let project = project_payload
.get("project")
.or_else(|| project_payload.pointer("/data/project"))
.ok_or_else(|| "画板项目响应缺少 project".to_string())?;
let resources = project
.get("resources")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "画板项目响应缺少 resources".to_string())?;
let import_root = format!(
"assets/canvas-sync/{}-{}",
sanitize_file_name(canvas_project_id),
unix_millis()
);
let mut assets = Vec::new();
for resource in resources {
let Some(resource_id) = json_string_field(resource, "resourceId") else {
continue;
};
let Some(download) =
resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource).await?
else {
continue;
};
let extension = infer_file_extension(
json_string_field(resource, "objectKey")
.or_else(|| json_string_field(resource, "imageSrc"))
.as_deref(),
&download.media_type,
);
let local_path = format!(
"{}/{}.{}",
import_root,
sanitize_file_name(&resource_id),
extension
);
let absolute_path = root.join(&local_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, &download.bytes).map_err(|error| {
format!("写入画板同步资产失败:{}: {error}", absolute_path.display())
})?;
assets.push(register_local_asset_entry(
root,
&local_path,
json_string_field(resource, "assetKind")
.as_deref()
.unwrap_or("canvas-resource"),
&download.media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id: Some(resource_id),
asset_object_id: json_string_field(resource, "assetObjectId"),
task_id: json_string_field(resource, "taskId"),
prompt: json_string_field(resource, "actualPrompt")
.or_else(|| json_string_field(resource, "prompt")),
model: json_string_field(resource, "model"),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?);
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.project_sync",
"canvasProjectId": canvas_project_id,
"importRoot": import_root,
"importedCount": assets.len(),
}),
)?;
Ok(SyncCanvasProjectAssetsResult {
canvas_project_id: canvas_project_id.to_string(),
import_root,
imported_count: assets.len(),
assets,
})
}
pub(crate) struct CanvasResourceDownload {
pub(crate) bytes: Vec<u8>,
pub(crate) media_type: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CanvasImageFormat {
Png,
Jpeg,
Webp,
Gif,
}
impl CanvasImageFormat {
fn from_media_type(media_type: &str) -> Option<Self> {
match media_type {
"image/png" => Some(Self::Png),
"image/jpeg" | "image/jpg" => Some(Self::Jpeg),
"image/webp" => Some(Self::Webp),
"image/gif" => Some(Self::Gif),
_ => None,
}
}
fn from_source_hint(source_hint: Option<&str>) -> Option<Self> {
let source = source_hint?
.split(['?', '#'])
.next()
.unwrap_or_default()
.to_ascii_lowercase();
match Path::new(&source)
.extension()
.and_then(|extension| extension.to_str())
{
Some("png") => Some(Self::Png),
Some("jpg" | "jpeg") => Some(Self::Jpeg),
Some("webp") => Some(Self::Webp),
Some("gif") => Some(Self::Gif),
_ => None,
}
}
fn media_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Gif => "image/gif",
}
}
fn matches_magic(self, bytes: &[u8]) -> bool {
match self {
Self::Png => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
Self::Jpeg => bytes.starts_with(&[0xff, 0xd8, 0xff]),
Self::Webp => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP",
Self::Gif => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
}
}
}
fn validate_canvas_downloaded_asset_content(
source_hint: Option<&str>,
image_source: bool,
media_type: &str,
bytes: &[u8],
) -> Result<(), String> {
let normalized_media_type = media_type
.split(';')
.next()
.unwrap_or(media_type)
.trim()
.to_ascii_lowercase();
let declared_format = CanvasImageFormat::from_media_type(&normalized_media_type);
let hinted_format = CanvasImageFormat::from_source_hint(source_hint);
if declared_format
.zip(hinted_format)
.is_some_and(|(declared, hinted)| declared != hinted)
{
return Err("画板图片响应格式与资源路径不一致".to_string());
}
if let Some(format) = declared_format.or(hinted_format) {
if !format.matches_magic(bytes) {
return Err(format!("画板图片内容与 {} 格式不匹配", format.media_type()));
}
} else if normalized_media_type.starts_with("image/") || image_source {
return Err("画板图片响应缺少可校验的受支持格式".to_string());
}
Ok(())
}
pub(crate) async fn resolve_canvas_resource_download(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit(
client,
api_base_url,
api_key,
resource,
20 * 1024 * 1024,
)
.await
}
fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool {
let Ok(api_base_url) = url::Url::parse(api_base_url) else {
return false;
};
url.scheme() == api_base_url.scheme()
&& url.host_str() == api_base_url.host_str()
&& url.port_or_known_default() == api_base_url.port_or_known_default()
}
fn external_asset_host_is_private(host: &str) -> bool {
let host = host.trim_start_matches('[').trim_end_matches(']');
if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
return true;
}
let Ok(address) = host.parse::<std::net::IpAddr>() else {
return false;
};
match address {
std::net::IpAddr::V4(address) => {
let [first, second, ..] = address.octets();
address.is_private()
|| address.is_loopback()
|| address.is_link_local()
|| address.is_broadcast()
|| address.is_unspecified()
|| address.is_multicast()
|| first == 0
|| (first == 100 && (64..=127).contains(&second))
|| (first == 198 && (18..=19).contains(&second))
}
std::net::IpAddr::V6(address) => {
address.is_loopback()
|| address.is_unspecified()
|| address.is_unique_local()
|| address.is_unicast_link_local()
|| address.is_multicast()
|| address
.to_ipv4_mapped()
.is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string()))
}
}
}
fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool {
match address {
std::net::IpAddr::V4(address) => {
let [first, second, ..] = address.octets();
first == 198 && (18..=19).contains(&second)
}
std::net::IpAddr::V6(_) => false,
}
}
fn external_asset_resolved_addresses_are_safe(
addresses: &[std::net::SocketAddr],
came_from_stable_reference: bool,
) -> bool {
if addresses
.iter()
.all(|address| !external_asset_host_is_private(&address.ip().to_string()))
{
return true;
}
// Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。
// 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外;
// 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。
came_from_stable_reference
&& !addresses.is_empty()
&& addresses
.iter()
.all(|address| external_asset_ip_is_proxy_benchmark(address.ip()))
}
pub(crate) fn validate_external_asset_download_url(
value: &str,
api_base_url: &str,
_came_from_stable_reference: bool,
) -> Result<url::Url, String> {
let url = url::Url::parse(value).map_err(|error| format!("画板资产下载地址无效:{error}"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err("画板资产下载地址只允许 HTTP(S)".to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("画板资产下载地址不能包含用户凭据".to_string());
}
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
// 配置中的 External Editor 本身可以是 localhost;同源媒体仍停留在这条已授权
// 边界内。任何跨 origin 的私网地址均拒绝,且下载客户端不跟随重定向。
if external_asset_host_is_private(host) && !external_asset_url_same_origin(&url, api_base_url) {
return Err("画板资产下载地址指向本机或私有网络,已拒绝请求".to_string());
}
Ok(url)
}
pub(crate) async fn build_external_asset_download_client(
url: &url::Url,
api_base_url: &str,
came_from_stable_reference: bool,
) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none());
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
let host_is_literal_or_local = host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.is_ok()
|| host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost");
if !host_is_literal_or_local && !external_asset_url_same_origin(url, api_base_url) {
let lookup_host = host.to_string();
let lookup_port = url
.port_or_known_default()
.ok_or_else(|| "画板资产下载地址缺少有效端口".to_string())?;
let addresses = tokio::task::spawn_blocking(move || {
std::net::ToSocketAddrs::to_socket_addrs(&(lookup_host.as_str(), lookup_port))
.map(|addresses| addresses.collect::<Vec<_>>())
})
.await
.map_err(|_| "解析画板资产下载域名的任务异常".to_string())?
.map_err(|error| format!("解析画板资产下载域名失败:{error}"))?;
if addresses.is_empty() {
return Err("画板资产下载域名没有可用地址".to_string());
}
if !external_asset_resolved_addresses_are_safe(&addresses, came_from_stable_reference) {
return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string());
}
builder = builder.resolve_to_addrs(host, &addresses);
}
builder
.build()
.map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))
}
pub(crate) async fn resolve_canvas_resource_download_with_limit(
_client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
max_bytes: usize,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
_client,
api_base_url,
api_key,
resource,
max_bytes,
&resolve_platform_editor_api_route("/api/external/v1/assets/read-url"),
)
.await
}
async fn resolve_canvas_resource_download_with_limit_and_route(
_client: &reqwest::Client,
api_base_url: &str,
bearer_token: &str,
resource: &serde_json::Value,
max_bytes: usize,
read_url_route: &str,
) -> Result<Option<CanvasResourceDownload>, String> {
if max_bytes == 0 {
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
}
let secure_client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))?;
let object_key = json_string_field(resource, "objectKey");
let image_src = json_string_field(resource, "imageSrc");
let source_hint = object_key.as_deref().or(image_src.as_deref());
let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() {
let read_url = format!(
"{}{read_url_route}?objectKey={}",
api_base_url,
percent_encode_query_component(object_key)
);
(
Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?),
true,
)
} else if let Some(image_src) = image_src.as_deref() {
if image_src.starts_with('/') {
let read_url = format!(
"{}{read_url_route}?legacyPublicPath={}",
api_base_url,
percent_encode_query_component(image_src)
);
(
Some(
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url)
.await?,
),
true,
)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
(Some(image_src.to_string()), false)
} else {
(None, false)
}
} else {
(None, false)
};
let Some(url) = signed_url else {
return Ok(None);
};
let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?;
let download_client =
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference)
.await?;
let mut response = download_client
.get(url)
.send()
.await
.map_err(|error| format!("下载画板资产失败:{error}"))?;
let status = response.status();
if status.is_redirection() {
return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string());
}
if !status.is_success() {
return Err(format!("下载画板资产失败:HTTP {}", status.as_u16()));
}
if response
.content_length()
.is_some_and(|size| size > max_bytes as u64)
{
return Err(format!(
"画板资产超过当前 {} 字节下载预算,已拒绝同步",
max_bytes
));
}
let media_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let mut bytes = Vec::with_capacity(
response
.content_length()
.and_then(|size| usize::try_from(size).ok())
.unwrap_or_default()
.min(max_bytes),
);
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("读取画板资产失败:{error}"))?
{
let next_len = bytes
.len()
.checked_add(chunk.len())
.ok_or_else(|| "画板资产下载大小溢出".to_string())?;
if next_len > max_bytes {
return Err(format!(
"画板资产超过当前 {} 字节下载预算,已拒绝同步",
max_bytes
));
}
bytes.extend_from_slice(&chunk);
}
validate_canvas_downloaded_asset_content(
source_hint,
image_src.is_some(),
&media_type,
bytes.as_slice(),
)?;
if bytes.is_empty() {
return Ok(None);
}
Ok(Some(CanvasResourceDownload { bytes, media_type }))
}
pub(crate) async fn resolve_external_asset_signed_url(
client: &reqwest::Client,
api_key: &str,
read_url: String,
) -> Result<String, String> {
let response = client
.get(read_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|error| format!("换签画板资产失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("换签画板资产失败:HTTP {}", status.as_u16()));
}
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析画板资产签名失败:{error}"))?;
json_string_field(
payload
.get("read")
.or_else(|| payload.pointer("/data/read"))
.unwrap_or(&serde_json::Value::Null),
"signedUrl",
)
.ok_or_else(|| "画板资产签名响应缺少 signedUrl".to_string())
}
pub(crate) fn resolve_canvas_sync_api_base_url(
api_base_url: Option<String>,
) -> Result<String, String> {
if let Some(credentials) = active_external_editor_api_credentials() {
return Ok(credentials.api_base_url);
}
if editor_api_mode() == EditorApiMode::PlatformAccount {
return current_platform_session()
.map(|session| session.api_base_url)
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
}
let config = load_game_creator_app_config()?;
let value = trim_optional_string(api_base_url)
.or_else(|| trim_config_string(&config.editor_api.base_url))
.unwrap_or_else(|| DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string());
let value = value.trim().trim_end_matches('/').to_string();
if value.starts_with("http://") || value.starts_with("https://") {
Ok(value)
} else {
Err("画板 API base_url 必须是 http(s) URL".to_string())
}
}
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
if let Some(credentials) = active_external_editor_api_credentials() {
return Ok(credentials.api_key);
}
if editor_api_mode() == EditorApiMode::PlatformAccount {
return current_platform_session()
.map(|session| session.access_token)
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
}
let config = load_game_creator_app_config()?;
trim_optional_string(api_key)
.or_else(|| trim_config_string(&config.editor_api.api_key))
.ok_or_else(|| {
format!(
"请在 {} 的 editorApi.apiKey 中设置开发者 API Key",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
})
}
pub(crate) fn resolve_canvas_sync_api_credentials(
api_base_url: Option<String>,
api_key: Option<String>,
) -> Result<(String, String, Option<PlatformSessionSnapshot>), String> {
if let Some(credentials) = active_external_editor_api_credentials() {
return Ok((credentials.api_base_url, credentials.api_key, None));
}
if editor_api_mode() == EditorApiMode::PlatformAccount {
let session = current_platform_session()
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
return Ok((
session.api_base_url.clone(),
session.access_token.clone(),
Some(session),
));
}
Ok((
resolve_canvas_sync_api_base_url(api_base_url)?,
resolve_canvas_sync_api_key(api_key)?,
None,
))
}
pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String {
resolve_platform_editor_api_route_for_mode(
external_route,
platform_editor_uses_account_routes(),
)
}
fn resolve_platform_editor_api_route_for_mode(
external_route: &str,
platform_session_available: bool,
) -> String {
if !platform_session_available {
return external_route.to_string();
}
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") {
return format!("/api/editor/{suffix}");
}
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") {
return format!("/api/assets/{suffix}");
}
external_route.to_string()
}
pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String {
resolve_platform_generation_status_route_for_mode(
operation_id,
platform_editor_uses_account_routes(),
)
}
pub(crate) fn editor_api_authentication_error() -> String {
match (platform_editor_uses_account_routes(), editor_api_mode()) {
(false, _) => "authentication-required: External Editor API Key 无效或权限不足".to_string(),
(true, EditorApiMode::PlatformAccount) => {
"authentication-required: 陶泥儿登录已失效,请重新登录".to_string()
}
(true, EditorApiMode::ExternalDeveloper) => {
unreachable!("external developer mode never uses account routes")
}
}
}
pub(crate) fn editor_api_authorization_error() -> String {
match (platform_editor_uses_account_routes(), editor_api_mode()) {
(false, _) => "permission-denied: External Editor API Key 权限不足".to_string(),
(true, EditorApiMode::PlatformAccount) => {
"permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string()
}
(true, EditorApiMode::ExternalDeveloper) => {
unreachable!("external developer mode never uses account routes")
}
}
}
fn resolve_platform_generation_status_route_for_mode(
operation_id: &str,
platform_session_available: bool,
) -> String {
let operation_id = percent_encode_query_component(operation_id);
if platform_session_available {
format!("/api/runtime/external-generation/jobs/{operation_id}")
} else {
format!("/api/external/v1/generations/{operation_id}")
}
}
pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value {
let data = payload.get("data").unwrap_or(payload);
data.get("job")
.filter(|job| job.is_object())
.unwrap_or(data)
}
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
pub(crate) fn percent_encode_query_component(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![byte as char]
}
_ => format!("%{byte:02X}").chars().collect(),
})
.collect()
}
pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'static str {
if let Some(source) = source {
let path = source.split('?').next().unwrap_or(source);
if path.ends_with(".png") {
return "png";
}
if path.ends_with(".jpg") || path.ends_with(".jpeg") {
return "jpg";
}
if path.ends_with(".webp") {
return "webp";
}
if path.ends_with(".gif") {
return "gif";
}
if path.ends_with(".mp3") {
return "mp3";
}
if path.ends_with(".wav") {
return "wav";
}
if path.ends_with(".mp4") {
return "mp4";
}
}
match media_type.split(';').next().unwrap_or(media_type).trim() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"audio/mpeg" => "mp3",
"audio/wav" | "audio/x-wav" => "wav",
"video/mp4" => "mp4",
_ => "bin",
}
}
pub(crate) fn read_canvas_export_metadata_from_zip(
archive: &mut zip::ZipArchive<File>,
) -> Result<(String, CanvasExportMetadata), String> {
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取画板导出 ZIP 条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let name = normalized_zip_entry_name(entry.name())?;
if !name.ends_with("metadata.json") {
continue;
}
let mut content = String::new();
entry
.read_to_string(&mut content)
.map_err(|error| format!("读取画板导出 metadata.json 失败:{error}"))?;
if let Ok(metadata) = serde_json::from_str::<CanvasExportMetadata>(&content) {
return Ok((name, metadata));
}
}
Err("画板导出 ZIP 缺少根 metadata.json".to_string())
}
pub(crate) fn extract_canvas_export_zip_files(
root: &Path,
archive: &mut zip::ZipArchive<File>,
zip_root_prefix: &str,
import_relative_root: &str,
) -> Result<Vec<String>, String> {
let mut copied_files = Vec::new();
let mut total_bytes = 0_u64;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取画板导出 ZIP 条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let name = normalized_zip_entry_name(entry.name())?;
if !name.starts_with(zip_root_prefix) {
continue;
}
let relative_in_export = name
.strip_prefix(zip_root_prefix)
.ok_or_else(|| "画板导出 ZIP 目录结构非法".to_string())?;
if relative_in_export.is_empty() {
continue;
}
if entry.size() > MAX_CANVAS_EXPORT_BYTES {
return Err("画板导出 ZIP 单文件过大".to_string());
}
total_bytes = total_bytes
.checked_add(entry.size())
.ok_or_else(|| "画板导出 ZIP 文件过大".to_string())?;
if total_bytes > MAX_CANVAS_EXPORT_BYTES {
return Err("画板导出 ZIP 解压体积过大".to_string());
}
if copied_files.len() >= MAX_CANVAS_EXPORT_FILES {
return Err("画板导出 ZIP 文件数量过多".to_string());
}
let normalized_relative = normalize_relative_path(relative_in_export)?;
let local_relative_path = format!("{import_relative_root}/{normalized_relative}");
let target_path = resolve_local_project_path(root, &local_relative_path)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?;
}
let mut output = File::create(&target_path)
.map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?;
std::io::copy(&mut entry, &mut output)
.map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?;
copied_files.push(normalized_relative);
}
if copied_files.is_empty() {
return Err("画板导出 ZIP 没有可导入文件".to_string());
}
Ok(copied_files)
}
pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
if name.contains('\\') || name.contains(':') || name.starts_with('/') {
return Err("画板导出 ZIP 条目路径非法".to_string());
}
let normalized = name.trim_matches('/').to_string();
if normalized.is_empty() {
return Err("画板导出 ZIP 条目路径为空".to_string());
}
normalize_relative_path(&normalized)
}
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> &'static str {
let layer_type = layer.visible.layer_type.as_str();
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
return "animation";
}
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
return "audio";
}
if contains_any(layer_type, &["角色"]) {
return "character";
}
if contains_any(layer_type, &["场景", "背景"]) {
return "scene";
}
if contains_any(layer_type, &["UI", "界面", "图标"]) {
return "ui";
}
"asset"
}
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
if file.starts_with("sequences/") {
return "application/vnd.genarrative.image-sequence";
}
match Path::new(file)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("ogg") => "audio/ogg",
Some("aac") => "audio/aac",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("mov") => "video/quicktime",
Some("json") => "application/json",
Some("txt") => "text/plain",
_ => "application/octet-stream",
}
}
pub(crate) fn build_canvas_project_url(
editor_base_url: Option<&str>,
canvas_project_id: Option<&str>,
) -> Result<String, String> {
let base_url = editor_base_url
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_EDITOR_BASE_URL);
let mut url =
tauri::Url::parse(base_url).map_err(|_| "画板地址必须是本地 HTTP 地址".to_string())?;
if url.scheme() != "http" {
return Err("画板地址必须使用本地 HTTP".to_string());
}
match url.host_str() {
Some("127.0.0.1" | "localhost") => {}
_ => return Err("画板地址只能指向本机编辑器".to_string()),
}
url.set_path("/editor/canvas");
url.set_query(None);
url.set_fragment(None);
if let Some(project_id) = canvas_project_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if project_id.chars().any(char::is_control) {
return Err("画板项目 ID 不能包含控制字符".to_string());
}
url.query_pairs_mut()
.append_pair("projectid", project_id)
.finish();
}
Ok(url.to_string())
}
pub(crate) fn register_local_asset_entry(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let manifest_path = root.join(".agent/manifest.json");
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
} else {
media_type
};
let mut source_for_record = source.clone();
source_for_record.prompt = None;
let (id, record_type) = mutate_manifest_at(root, |manifest| {
if let Some(existing) = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == normalized_path)
{
existing.kind = kind.to_string();
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update"))
} else {
let id = format!(
"{id_prefix}-{}-{}",
unix_millis(),
manifest.assets.len() + 1
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: kind.to_string(),
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
source,
});
Ok((id, "asset.register"))
}
})?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,),
"/api/editor/projects"
);
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,),
"/api/assets/read-url"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation/a b", true),
"/api/runtime/external-generation/jobs/operation%2Fa%20b"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation/a b", false),
"/api/external/v1/generations/operation%2Fa%20b"
);
let payload = serde_json::json!({
"data": {
"job": {
"operationId": "operation-1",
"status": "completed",
"result": { "objectKey": "generated/result.png" },
}
}
});
assert_eq!(
platform_generation_status_data(&payload)["result"]["objectKey"],
"generated/result.png"
);
}
#[test]
fn advanced_external_mode_keeps_external_routes_without_platform_session() {
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,),
"/api/external/v1/editor/projects"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation-1", false),
"/api/external/v1/generations/operation-1"
);
}
#[test]
fn private_external_editor_credentials_require_a_trusted_origin_and_key_shape() {
assert_eq!(
normalize_external_editor_api_base_url("https://dev.genarrative.world/")
.expect("trusted dev origin"),
"https://dev.genarrative.world"
);
assert_eq!(
normalize_external_editor_api_base_url("http://127.0.0.1:8085/"),
Ok("http://127.0.0.1:8085".to_string())
);
assert!(normalize_external_editor_api_base_url("http://staging.example.com").is_err());
assert_eq!(
normalize_external_editor_api_base_url("https://untrusted.example.test"),
Ok("https://untrusted.example.test".to_string())
);
assert!(
normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err()
);
assert_eq!(
normalize_external_editor_api_key(" tnr_sk_fixture_123 ").expect("fixture API key"),
"tnr_sk_fixture_123"
);
assert!(normalize_external_editor_api_key("plain-token").is_err());
assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err());
}
#[test]
fn private_external_editor_credentials_are_isolated_by_server_origin() {
let local = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8082")
.expect("local credential path");
let previous = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085")
.expect("previous local credential path");
let dev =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("dev credential path");
assert_ne!(local, previous);
assert_ne!(local, dev);
assert!(local
.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|name| name.starts_with(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)));
}
#[test]
fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() {
assert!(
private_external_editor_credentials_storage_preparation_failed(
"private-external-editor-credential-storage-preparation-failed: safe public detail"
)
);
assert!(private_external_editor_credentials_persistence_failed(
"private-external-editor-credential-persistence-failed: safe public detail"
));
assert!(
!private_external_editor_credentials_storage_preparation_failed("ordinary failure")
);
assert!(!private_external_editor_credentials_persistence_failed(
"ordinary failure"
));
}
#[cfg(windows)]
#[test]
fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() {
let root = tempfile::tempdir().expect("temp dir");
let path = root
.path()
.join("config")
.join("genarrative")
.join("external-editor-api-test.json");
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("prepare private credential directory");
let parent = path.parent().expect("credential directory");
secure_windows_game_creator_path_for_current_user(parent, true, false)
.expect("new credential directory must be owned by TokenUser");
}
#[tokio::test]
async fn direct_runtime_private_credentials_force_external_v1_routes_without_session_copy() {
let credentials = ExternalEditorApiCredentials {
api_base_url: "https://dev.genarrative.world".to_string(),
api_key: "tnr_sk_fixture_123".to_string(),
};
with_external_editor_api_credentials(credentials, async {
assert!(external_editor_api_credentials_override_is_active());
assert!(!platform_editor_uses_account_routes());
assert_eq!(
resolve_canvas_sync_api_base_url(None).expect("scoped API base URL"),
"https://dev.genarrative.world"
);
assert_eq!(
resolve_canvas_sync_api_key(None).expect("scoped API key"),
"tnr_sk_fixture_123"
);
assert_eq!(
resolve_platform_editor_api_route("/api/external/v1/editor/projects"),
"/api/external/v1/editor/projects"
);
assert_eq!(
resolve_platform_generation_status_route("operation-1"),
"/api/external/v1/generations/operation-1"
);
})
.await;
assert!(!external_editor_api_credentials_override_is_active());
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set asset test read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).expect("read asset test request");
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
}
}
#[test]
fn canvas_download_accepts_supported_image_magic() {
let cases: [(&str, &str, &[u8]); 4] = [
(
"image/png; charset=binary",
"assets/hero.png",
b"\x89PNG\r\n\x1a\nbody",
),
("IMAGE/JPEG", "assets/hero.jpeg", &[0xff, 0xd8, 0xff, 0xe0]),
(
"image/webp",
"assets/hero.webp?version=1",
b"RIFF\x04\x00\x00\x00WEBP",
),
("image/gif", "assets/hero.gif#frame", b"GIF89abody"),
];
for (media_type, source_hint, bytes) in cases {
validate_canvas_downloaded_asset_content(Some(source_hint), false, media_type, bytes)
.unwrap_or_else(|error| panic!("{media_type} should pass: {error}"));
}
}
#[test]
fn canvas_download_rejects_error_bodies_and_wrong_image_magic() {
for (media_type, source_hint) in [
("image/png", "assets/hero.png"),
("image/jpeg", "assets/hero.jpg"),
("image/webp", "assets/hero.webp"),
("image/gif", "assets/hero.gif"),
] {
let error = validate_canvas_downloaded_asset_content(
Some(source_hint),
false,
media_type,
br#"{"error":"generation failed"}"#,
)
.expect_err("200 error body must fail image validation");
assert!(error.contains("格式不匹配"));
}
}
#[test]
fn canvas_download_uses_image_path_when_response_mime_is_not_image() {
let error = validate_canvas_downloaded_asset_content(
Some("assets/hero.png"),
false,
"application/json",
br#"{"error":"expired signed url"}"#,
)
.expect_err("image path must still require valid image bytes");
assert!(error.contains("image/png"));
}
#[test]
fn canvas_download_rejects_unverifiable_image_source_but_leaves_media_unchanged() {
let error = validate_canvas_downloaded_asset_content(
Some("https://example.test/generated"),
true,
"text/html",
b"upstream error",
)
.expect_err("imageSrc response without a supported format must fail closed");
assert!(error.contains("缺少可校验"));
validate_canvas_downloaded_asset_content(
Some("assets/theme.mp3"),
false,
"audio/mpeg",
b"audio-validation-remains-unchanged",
)
.expect("audio downloads are outside image magic validation");
validate_canvas_downloaded_asset_content(
Some("assets/intro.mp4"),
false,
"video/mp4",
b"video-validation-remains-unchanged",
)
.expect("video downloads are outside image magic validation");
}
#[test]
fn canvas_download_url_blocks_private_direct_sources_but_allows_configured_resign_origin() {
for url in [
"http://127.0.0.1/internal.png",
"http://169.254.169.254/latest/meta-data",
"http://[::1]/internal.png",
"http://localhost/internal.png",
] {
assert!(
validate_external_asset_download_url(url, "http://127.0.0.1:3101", false,).is_err()
);
}
validate_external_asset_download_url(
"http://127.0.0.1:3101/api/assets/object/stable.png",
"http://127.0.0.1:3101",
true,
)
.expect("configured External Editor origin may serve a resigned stable object");
validate_external_asset_download_url(
"https://cdn.example.test/assets/stable.png",
"http://127.0.0.1:3101",
false,
)
.expect("public HTTPS asset is allowed");
}
#[test]
fn canvas_download_only_allows_proxy_fake_ips_for_stable_resigned_references() {
let fake_ip = ["198.18.0.73:443".parse().expect("parse proxy fake IP")];
assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true));
assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false));
for address in [
"127.0.0.1:443",
"10.0.0.1:443",
"169.254.169.254:80",
"[::1]:443",
] {
let addresses = [address.parse().expect("parse private address")];
assert!(!external_asset_resolved_addresses_are_safe(
&addresses, true
));
}
let mixed = [
"198.18.0.73:443".parse().expect("parse proxy fake IP"),
"203.0.113.10:443".parse().expect("parse public fixture IP"),
];
assert!(!external_asset_resolved_addresses_are_safe(&mixed, true));
}
#[tokio::test]
async fn canvas_download_rejects_redirects_before_following_private_targets() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind redirect download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
read_asset_test_request(&mut signing);
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
write!(
signing,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write signing response");
let (mut download, _) = listener.accept().expect("accept asset request");
read_asset_test_request(&mut download);
write!(
download,
"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.expect("write redirect response");
});
let error = resolve_canvas_resource_download(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
)
.await
.err()
.expect("redirect must fail closed");
server.join().expect("join redirect fixture");
assert!(error.contains("重定向"));
}
#[tokio::test]
async fn canvas_download_applies_remaining_budget_before_buffering_body() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind bounded download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
read_asset_test_request(&mut signing);
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
write!(
signing,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write signing response");
let (mut download, _) = listener.accept().expect("accept bounded asset request");
read_asset_test_request(&mut download);
write!(
download,
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 9\r\nConnection: close\r\n\r\n"
)
.expect("write oversized response headers");
});
let error = resolve_canvas_resource_download_with_limit(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
8,
)
.await
.err()
.expect("content length over remaining budget must fail before buffering");
server.join().expect("join bounded fixture");
assert!(error.contains("下载预算"));
}
}