Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs
T
k88936 5d20f5bfeb
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 7m9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 7m20s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m24s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 8m10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / Repository checks (pull_request) Failing after 4m43s
Project CI / Frontend tests (pull_request) Failing after 4m51s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
修复 AGC 资源 kind 枚举化后的编译与字符串回退
- canvas_generation:参考素材合同四个 helper 改收 GameCreationAppAssetKind,删除 ui-prototype/game-background/art-spritesheet 字符串比较

- direct_runtime:删除 game-background / art-spritesheet 的参考合同字符串映射,直接传枚举

- resource_editor:音效/BGM 编辑类型判据改为比较 ResourceEditSourceKind::Manifest(BackgroundMusic),不再拼字符串 haystack

- assets/commands:画板导入 kind 解析上移到 Tauri 命令边界,import_canvas_asset_at 只接收枚举

- 测试与批量标签脚手架:kind 参数由字符串改为 GameCreationAppAssetKind,恢复编译期穷举保护

- 技术方案:登记边界同口径改为描述命令边界解析
2026-09-19 02:38:30 +08:00

3051 lines
116 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use sha2::{Digest as _, Sha256};
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
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
}
/// Runs a direct AGC operation with the authentication mode owned by the
/// current build. Debug/client builds use the logged-in platform account
/// session directly; the external-developer release mode keeps its private
/// local credential isolated from the agent. This must not turn account-mode
/// requests into external API-key requests.
pub(crate) async fn with_direct_editor_api_credentials<T>(
operation: impl Future<Output = Result<T, String>>,
) -> Result<T, String> {
if editor_api_mode() == EditorApiMode::PlatformAccount {
return operation.await;
}
let credentials = ensure_private_external_editor_api_credentials().await?;
with_external_editor_api_credentials(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> {
if !crate::prepare_game_creator_private_path_for_read(path, false, "本机陶泥儿开发者 Key 文件")?
{
return Ok(None);
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES {
return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string());
}
let content = crate::read_game_creator_private_file_to_string(
path,
"本机陶泥儿开发者 Key 配置",
PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES,
)?;
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),
)?;
let expected_fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes()));
let actual_fingerprint = path
.file_name()
.and_then(|value| value.to_str())
.and_then(|value| {
value
.strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)
.and_then(|value| value.strip_suffix(".json"))
})
.filter(|value| value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
.ok_or_else(|| "本机陶泥儿开发者 Key 文件名身份无效,请重新登录客户端后重试".to_string())?;
if !actual_fingerprint.eq_ignore_ascii_case(&expected_fingerprint[..16]) {
return Err(
"本机陶泥儿开发者 Key 文件身份与服务器地址不一致,请重新登录客户端后重试".to_string(),
);
}
Ok(Some(ExternalEditorApiCredentials {
api_base_url,
api_key,
}))
}
fn unique_private_external_editor_api_credentials_at(
directory: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
if !crate::prepare_game_creator_private_path_for_read(
directory,
true,
"本机陶泥儿开发者 Key 目录",
)? {
return Ok(None);
}
let mut candidates = fs::read_dir(directory)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name();
let name = name.to_str()?;
let fingerprint = name
.strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)?
.strip_suffix(".json")?;
(fingerprint.len() == 16 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(entry.path())
})
.collect::<Vec<_>>();
candidates.sort();
if candidates.len() > 8 {
return Err("本机陶泥儿开发者 Key 候选过多,已拒绝自动选择".to_string());
}
let mut credentials = Vec::new();
for path in candidates {
if let Some(candidate) = private_external_editor_api_credentials_from_file_at(&path)? {
credentials.push(candidate);
}
}
match credentials.len() {
0 => Ok(None),
1 => Ok(credentials.pop()),
_ => Err(
"本机存在多个陶泥儿服务器的开发者 Key;请先在客户端登录目标服务器后重试".to_string(),
),
}
}
fn ensure_plain_private_external_editor_directory(
path: &Path,
label: &str,
) -> Result<bool, String> {
let label = format!("本机陶泥儿开发者凭据{label}");
let created = crate::ensure_game_creator_private_directory_tree(path, &label)?;
#[cfg(windows)]
if !created {
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, true, true,
)?;
}
Ok(created)
}
/// Prepares the exact private directory before a one-time remote developer key
/// is requested. Existing directories must already belong to the current user,
/// but their DACL may be tightened locally; only a 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, "上级目录")?;
ensure_plain_private_external_editor_directory(parent, "目录")?;
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user_with_auto_elevation(parent, true, true)?;
#[cfg(unix)]
{
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());
}
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(
"本机陶泥儿开发者 Key 目标必须是普通文件,不能是链接或其他对象".to_string(),
);
}
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 目标失败:{error}"));
}
}
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);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&temporary)
.map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?;
crate::harden_new_game_creator_private_path(&temporary, false, "本机陶泥儿开发者 Key 临时文件")
.map_err(|error| {
let _ = fs::remove_file(&temporary);
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}"));
}
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)]
if let Err(error) =
secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
{
let _ = fs::remove_file(path);
return Err(format!(
"复核本机陶泥儿开发者 Key 文件安全权限失败:{error}"
));
}
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 = crate::http_client::agc_main_site_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 = crate::http_client::with_agc_main_site_marker(
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> {
if let Some(credentials) = active_external_editor_api_credentials() {
return Ok(credentials);
}
let Some(session) = current_platform_session() else {
return unique_private_external_editor_api_credentials_at(
&private_external_editor_api_key_directory()?,
)?
.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()
}
/// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。
/// 判不出内容类型时返回 `Unknown`;调用者负责决定是否拒绝写入或保留待后续归类,
/// 这里不猜具体类型。
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind {
let media_type = media_type.trim().to_ascii_lowercase();
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let extension = extension.as_str();
if media_type.starts_with("audio/")
|| matches!(
extension,
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
)
{
GameCreationAppAssetKind::Audio
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
GameCreationAppAssetKind::Video
} else if media_type.starts_with("font/")
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
{
GameCreationAppAssetKind::Font
} else if media_type.starts_with("image/")
|| matches!(
extension,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
)
{
GameCreationAppAssetKind::Image
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|| media_type.contains("javascript")
|| media_type.contains("typescript")
|| matches!(
extension,
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
)
{
GameCreationAppAssetKind::Code
} else if media_type.starts_with("text/")
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|| matches!(
extension,
"md" | "markdown"
| "mdx"
| "txt"
| "json"
| "yaml"
| "yml"
| "toml"
| "csv"
| "ini"
| "conf"
| "xml"
)
{
GameCreationAppAssetKind::Document
} else {
GameCreationAppAssetKind::Unknown
}
}
/// 登记边界的 kind 解析:空白入参按「没有信息」处理,沿用登记入口的 `Image` 默认值;
/// 非空但认不出的原值严格收口成 `Unknown` 并把原始串交给 reporter 留痕,
/// 由调用者决定是否以错误结束或继续后续归类。
///
/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、
/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。
/// 只做严格等值匹配,认不出时收口为 `Unknown`。
pub(crate) fn registration_asset_kind(
value: &str,
context: &'static str,
) -> GameCreationAppAssetKind {
if value.trim().is_empty() {
return GameCreationAppAssetKind::Image;
}
GameCreationAppAssetKind::parse_with_context(value, context)
}
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() {
ensure_game_creator_private_directory_tree(parent, "上传目录")?;
prepare_game_creator_private_path_for_read(parent, true, "上传目录")?;
}
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
register_local_asset_entry(
root,
&relative_path,
uploaded_asset_kind(file_name, media_type),
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: GameCreationAppAssetKind,
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 register_design_artifacts_at(root: &Path) -> Result<bool, String> {
let design_root = root.join("design_artifacts");
if !design_root.exists() {
return Ok(false);
}
let mut files = Vec::new();
let mut directories = vec![design_root];
while let Some(directory) = directories.pop() {
for entry in fs::read_dir(&directory)
.map_err(|error| format!("读取策划产物目录失败:{}: {error}", directory.display()))?
{
let entry = entry.map_err(|error| format!("读取策划产物失败:{error}"))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取策划产物元数据失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
directories.push(path);
} else if metadata.is_file() {
files.push(path);
}
}
}
files.sort();
let mut changed = false;
for path in files {
let relative = path
.strip_prefix(root)
.map_err(|_| "策划产物路径不在项目根目录内".to_string())?
.to_string_lossy()
.replace('\\', "/");
let media_type = match path.extension().and_then(|value| value.to_str()) {
Some("md") => "text/markdown",
Some("txt") => "text/plain",
Some("json") => "application/json",
Some("yaml" | "yml") => "text/yaml",
_ => "application/octet-stream",
};
let (_, asset_changed) = register_local_asset_entry_with_change(
root,
&relative,
GameCreationAppAssetKind::Document,
media_type,
"document",
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(),
},
None,
)?;
changed |= asset_changed;
}
Ok(changed)
}
pub(crate) fn import_canvas_asset_at(
root: &Path,
local_path: &str,
kind: GameCreationAppAssetKind,
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());
}
crate::prepare_game_creator_user_selected_path_for_read(export_path, false, "画板导出 ZIP")?;
let metadata = fs::symlink_metadata(export_path)
.map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
if metadata.file_type().is_symlink() || !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 = crate::http_client::agc_main_site_client_builder()
.build()
.map_err(|error| format!("创建画板同步客户端失败:{error}"))?;
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 = crate::http_client::with_agc_main_site_marker(
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() {
ensure_game_creator_private_directory_tree(parent, "画板同步目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板同步目录")?;
}
crate::write_game_creator_private_file(&absolute_path, &download.bytes, "画板同步资产")?;
assets.push(register_local_asset_entry(
root,
&local_path,
json_string_field(resource, "assetKind")
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
GameCreationAppAssetKind::parse_with_context(value, "canvas.asset_sync")
})
.unwrap_or(GameCreationAppAssetKind::Image),
&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
}
pub(crate) 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> {
resolve_canvas_resource_download_with_limit_route_and_fence(
_client,
api_base_url,
bearer_token,
resource,
max_bytes,
read_url_route,
|| Ok(()),
)
.await
}
pub(crate) async fn resolve_canvas_resource_download_with_limit_route_and_fence<F>(
_client: &reqwest::Client,
api_base_url: &str,
bearer_token: &str,
resource: &serde_json::Value,
max_bytes: usize,
read_url_route: &str,
mut fence: F,
) -> Result<Option<CanvasResourceDownload>, String>
where
F: FnMut() -> Result<(), String>,
{
if max_bytes == 0 {
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
}
let secure_client = crate::http_client::agc_main_site_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)
);
fence()?;
let signed_url =
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
fence()?;
(Some(signed_url?), 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)
);
fence()?;
let signed_url =
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
fence()?;
(Some(signed_url?), 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)?;
fence()?;
let download_client_result =
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference).await;
fence()?;
let download_client = download_client_result?;
fence()?;
let response_result = download_client.get(url).send().await;
fence()?;
let mut response = response_result.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),
);
loop {
fence()?;
let chunk = response.chunk().await;
fence()?;
let Some(chunk) = chunk.map_err(|error| format!("读取画板资产失败:{error}"))?
else {
break;
};
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 =
crate::http_client::with_agc_main_site_marker(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);
match Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => return "png",
Some("jpg" | "jpeg") => return "jpg",
Some("webp") => return "webp",
Some("gif") => return "gif",
Some("svg") => return "svg",
Some("avif") => return "avif",
Some("bmp") => return "bmp",
Some("ttf") => return "ttf",
Some("otf") => return "otf",
Some("woff") => return "woff",
Some("woff2") => return "woff2",
Some("mp3") => return "mp3",
Some("wav") => return "wav",
Some("ogg") => return "ogg",
Some("flac") => return "flac",
Some("m4a") => return "m4a",
Some("aac") => return "aac",
Some("opus") => return "opus",
Some("mp4") => return "mp4",
Some("webm") => return "webm",
Some("mov") => return "mov",
Some("md") => return "md",
Some("markdown") => return "markdown",
Some("mdx") => return "mdx",
Some("txt") => return "txt",
Some("json") => return "json",
Some("yaml") => return "yaml",
Some("yml") => return "yml",
Some("toml") => return "toml",
Some("csv") => return "csv",
Some("ini") => return "ini",
Some("conf") => return "conf",
Some("xml") => return "xml",
Some("html") => return "html",
Some("htm") => return "htm",
Some("css") => return "css",
Some("scss") => return "scss",
Some("less") => return "less",
Some("js") => return "js",
Some("mjs") => return "mjs",
Some("cjs") => return "cjs",
Some("ts") => return "ts",
Some("tsx") => return "tsx",
Some("gd") => return "gd",
Some("rs") => return "rs",
Some("py") => return "py",
Some("go") => return "go",
Some("java") => return "java",
Some("kt") => return "kt",
Some("kts") => return "kts",
Some("c") => return "c",
Some("cc") => return "cc",
Some("cpp") => return "cpp",
Some("h") => return "h",
Some("hpp") => return "hpp",
Some("cs") => return "cs",
Some("swift") => return "swift",
Some("php") => return "php",
Some("rb") => return "rb",
Some("lua") => return "lua",
Some("sh") => return "sh",
Some("bash") => return "bash",
Some("zsh") => return "zsh",
Some("sql") => return "sql",
Some("graphql") => return "graphql",
Some("gql") => return "gql",
Some("vue") => return "vue",
Some("svelte") => return "svelte",
_ => {}
}
}
match media_type.split(';').next().unwrap_or(media_type).trim() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"image/avif" => "avif",
"image/bmp" => "bmp",
"font/ttf" => "ttf",
"font/otf" => "otf",
"font/woff" => "woff",
"font/woff2" => "woff2",
"audio/mpeg" => "mp3",
"audio/wav" | "audio/x-wav" => "wav",
"audio/ogg" => "ogg",
"audio/flac" => "flac",
"audio/mp4" => "m4a",
"audio/aac" => "aac",
"audio/opus" => "opus",
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/quicktime" => "mov",
"application/json" => "json",
"application/yaml" => "yaml",
"application/xml" => "xml",
"text/html" => "html",
"text/css" => "css",
"text/plain" => "txt",
_ => "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() {
ensure_game_creator_private_directory_tree(parent, "画板导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板导入目录")?;
}
let entry_size = entry.size();
let mut bytes = Vec::with_capacity(entry_size.min(MAX_CANVAS_EXPORT_BYTES as u64) as usize);
std::io::Read::take(&mut entry, entry_size + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?;
if bytes.len() as u64 != entry_size {
return Err("画板导出 ZIP 条目读取长度不一致".to_string());
}
crate::write_game_creator_private_file(&target_path, &bytes, "画板导入文件")?;
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)
}
/// 画板导出图层推断出的资源 kind。
///
/// 直接返回正式枚举成员;该值会被登记边界原样写入 manifest。
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> GameCreationAppAssetKind {
let layer_type = layer.visible.layer_type.as_str();
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
GameCreationAppAssetKind::CharacterAnimation
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"])
{
GameCreationAppAssetKind::Audio
} else if contains_any(layer_type, &["角色"]) {
GameCreationAppAssetKind::Character
} else if contains_any(layer_type, &["场景", "背景"]) {
GameCreationAppAssetKind::Scene
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
GameCreationAppAssetKind::UiDesign
} else {
GameCreationAppAssetKind::Image
}
}
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: GameCreationAppAssetKind,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_with_change(
root, local_path, kind, media_type, id_prefix, source, None,
)
.map(|(result, _)| result)
}
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
///
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`,
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`],
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
/// [`register_local_asset_entry`],行为不变。
pub(crate) fn register_local_asset_entry_with_category(
root: &Path,
local_path: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
let target_category = normalize_asset_category_override(target_category)?;
register_local_asset_entry_with_change(
root,
local_path,
kind,
media_type,
id_prefix,
source,
target_category,
)
.map(|(result, _)| result)
}
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
fn normalize_asset_category_override(
target_category: Option<&str>,
) -> Result<Option<GameCreationAppAssetCategory>, String> {
let Some(target_category) = target_category else {
return Ok(None);
};
let target_category = target_category.trim();
if target_category.is_empty() {
return Ok(None);
}
game_creation_app_asset_category_from_str(target_category)
.map(Some)
.ok_or_else(|| format!("非法资源分类:{target_category}"))
}
fn register_local_asset_entry_with_change(
root: &Path,
local_path: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<GameCreationAppAssetCategory>,
) -> Result<(UploadLocalAssetResult, bool), 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 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, changed) = mutate_manifest_at(root, |manifest| {
if let Some(existing) = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == normalized_path)
{
// kind 变了必须重派生 category:否则同路径重登记会把新 kind 和旧分类拼在一起,
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
// 时才触发),于是这个资产永远停在错误栏目。
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
let changed = existing.kind != kind
|| existing.media_type != media_type
|| existing.source != source
|| target_category.is_some_and(|category| existing.category != category);
if existing.kind != kind {
existing.kind = kind;
existing.category = game_creation_app_asset_category_for_kind(kind);
}
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
if let Some(category) = target_category {
existing.category = category;
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update", changed))
} else {
let id = format!(
"{id_prefix}-{}-{}",
unix_millis(),
manifest.assets.len() + 1
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind,
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: target_category
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
tags: Vec::new(),
source,
});
Ok((id, "asset.register", true))
}
})?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind.as_str(),
"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(),
},
changed,
))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesInput {
pub project_path: String,
pub asset_id: String,
}
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectAssetReferenceVersion {
pub version_id: String,
pub project_revision: u64,
pub created_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesResult {
pub asset_id: String,
pub versions: Vec<LocalProjectAssetReferenceVersion>,
}
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
///
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
fn asset_referencing_versions(
manifest: &GameCreationAppManifest,
asset_id: &str,
) -> Vec<LocalProjectAssetReferenceVersion> {
manifest
.versions
.iter()
.filter(|version| {
version
.resource_bindings
.iter()
.any(|binding| binding.resource_id == asset_id)
})
.map(|version| LocalProjectAssetReferenceVersion {
version_id: version.version_id.clone(),
project_revision: version.project_revision,
created_at: version.created_at,
})
.collect()
}
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材 assetId 不能为空".to_string());
}
Ok(asset_id)
}
pub(crate) fn read_manifest_asset_references_at(
root: &Path,
asset_id: &str,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let asset_id = normalized_asset_id(asset_id)?;
let manifest = read_existing_manifest_for_project(root)?;
Ok(ReadLocalProjectAssetReferencesResult {
asset_id: asset_id.to_string(),
versions: asset_referencing_versions(&manifest, asset_id),
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetInput {
pub project_path: String,
pub expected_project_id: String,
pub expected_project_revision: u64,
pub asset_id: String,
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
pub delete_referenced_versions: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetResult {
pub asset_id: String,
pub local_path: String,
pub committed_project_revision: u64,
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
pub file_retained: bool,
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 语义:
/// - 未被任何版本引用 → 只摘掉登记;
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS,
/// 失败时 manifest 与 revision 都不变。
/// - **幂等**:CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision
/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision
/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
delete_referenced_versions: bool,
) -> Result<DeleteLocalProjectAssetResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("素材删除 expectedProjectId 不能为空".to_string());
}
let asset_id = normalized_asset_id(asset_id)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
let mut local_path = String::new();
mutate_manifest_at_allowing_version_removals(
root,
&|manifest| {
if !delete_referenced_versions {
return Vec::new();
}
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect()
},
|manifest| {
let Some(index) = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
else {
// 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败
// (manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与
// `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当
// no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。
// 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。
return Ok(());
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
// 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains`
// 在引用该素材的版本很多时是 O(V²)。
// (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次,
// 它拿不到这里的可变借用,所以两次扫描无法合并。)
let referenced_version_ids: std::collections::HashSet<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect();
manifest
.versions
.retain(|version| !referenced_version_ids.contains(&version.version_id));
}
Ok(())
},
)?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
Ok(DeleteLocalProjectAssetResult {
asset_id: asset_id.to_string(),
local_path,
committed_project_revision,
file_retained: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::{Read, Write};
#[test]
fn design_artifact_registration_reports_only_real_manifest_changes() {
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
.expect("init project");
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
.expect("write artifact");
assert!(register_design_artifacts_at(root).expect("register first time"));
assert_eq!(
read_existing_manifest_for_project(root)
.unwrap()
.assets
.len(),
1
);
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
}
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
///
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
#[test]
fn explicit_target_category_overrides_the_kind_derived_category() {
fn canvas_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
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(),
}
}
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("registered asset is present")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
.expect("init project");
fs::create_dir_all(root.join("assets")).expect("create assets dir");
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
let registered = register_local_asset_entry_with_category(
root,
"assets/hero.png",
GameCreationAppAssetKind::Image,
"image/png",
"platform-art",
canvas_source(),
Some("character"),
)
.expect("register with a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::Character
);
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
register_local_asset_entry_with_category(
root,
"assets/hero.png",
GameCreationAppAssetKind::Image,
"image/png",
"platform-art",
canvas_source(),
Some("ui-interaction"),
)
.expect("re-register with another target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 非法值失败关闭,且不动已落盘的分类。
assert!(register_local_asset_entry_with_category(
root,
"assets/hero.png",
GameCreationAppAssetKind::Image,
"image/png",
"platform-art",
canvas_source(),
Some("version"),
)
.is_err());
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
let plain = register_local_asset_entry(
root,
"assets/plain.png",
GameCreationAppAssetKind::Image,
"image/png",
"platform-art",
canvas_source(),
)
.expect("register without a target category");
assert_eq!(
category_of(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
register_local_asset_entry(
root,
"assets/hero.png",
GameCreationAppAssetKind::Image,
"image/png",
"platform-art",
canvas_source(),
)
.expect("re-register without a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
#[test]
fn canvas_export_asset_kind_is_always_canonical() {
fn layer(layer_type: &str) -> CanvasExportLayerMetadata {
serde_json::from_value(serde_json::json!({
"title": "测试图层",
"file": "layer.png",
"exportError": null,
"visible": {
"type": layer_type,
"model": "-",
"task": "-",
"object": "-",
},
}))
.expect("canvas export layer metadata")
}
// 每个分支的代表输入 → 期望的 canonical kind。
let cases: [(&str, &str, GameCreationAppAssetKind); 6] = [
(
"序列",
"sequences/01.png",
GameCreationAppAssetKind::CharacterAnimation,
),
(
"动画",
"layer.png",
GameCreationAppAssetKind::CharacterAnimation,
),
("音频", "layer.png", GameCreationAppAssetKind::Audio),
("角色", "layer.png", GameCreationAppAssetKind::Character),
("场景", "layer.png", GameCreationAppAssetKind::Scene),
("UI", "layer.png", GameCreationAppAssetKind::UiDesign),
// 无匹配时落到 image。
];
for (layer_type, file, expected) in cases {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), file);
assert_eq!(kind, expected, "layer_type={layer_type} file={file}");
}
// media/ 前缀同样走音频分支。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
GameCreationAppAssetKind::Audio
);
// 无匹配时使用中性的 image。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
GameCreationAppAssetKind::Image
);
// 所有分支的返回值都必须是正式枚举成员。
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
assert!(
GameCreationAppAssetKind::ALL.contains(&kind),
"非 canonical kind: {kind}(layer_type={layer_type})"
);
}
}
#[test]
fn uploaded_asset_kind_uses_content_evidence() {
assert_eq!(
uploaded_asset_kind("hero.png", "image/png"),
GameCreationAppAssetKind::Image
);
assert_eq!(
uploaded_asset_kind("theme.mp3", "audio/mpeg"),
GameCreationAppAssetKind::Audio
);
assert_eq!(
uploaded_asset_kind("intro.mp4", ""),
GameCreationAppAssetKind::Video
);
assert_eq!(
uploaded_asset_kind("rules.md", "text/markdown"),
GameCreationAppAssetKind::Document
);
assert_eq!(
uploaded_asset_kind("ui-font.ttf", "font/ttf"),
GameCreationAppAssetKind::Font
);
assert_eq!(
uploaded_asset_kind("game.js", "text/javascript"),
GameCreationAppAssetKind::Code
);
assert_eq!(
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
GameCreationAppAssetKind::Unknown
);
}
#[test]
fn infer_file_extension_preserves_supported_local_resource_extensions() {
assert_eq!(
infer_file_extension(Some("game/Index.HTML"), "text/html"),
"html"
);
assert_eq!(
infer_file_extension(Some("assets/theme.MP3"), "audio/mpeg"),
"mp3"
);
assert_eq!(
infer_file_extension(Some("assets/font.woff2"), "font/woff2"),
"woff2"
);
assert_eq!(infer_file_extension(None, "application/json"), "json");
}
#[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 unique_private_external_editor_credentials_support_headless_recovery_only_when_unambiguous()
{
let root = tempfile::tempdir().expect("temp dir");
let directory = root.path().join("config").join("genarrative");
let first = ExternalEditorApiCredentials {
api_base_url: "https://dev.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_1".to_string(),
};
let first_path = directory.join(
private_external_editor_api_key_path_for_base_url(&first.api_base_url)
.expect("first credential path")
.file_name()
.expect("first credential filename"),
);
write_private_external_editor_api_credentials_at(&first_path, &first)
.expect("write first private credential");
let recovered = unique_private_external_editor_api_credentials_at(&directory)
.expect("read unique private credential")
.expect("credential present");
assert_eq!(recovered.api_base_url, first.api_base_url);
assert_eq!(recovered.api_key, first.api_key);
let second = ExternalEditorApiCredentials {
api_base_url: "https://www.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_2".to_string(),
};
let second_path = directory.join(
private_external_editor_api_key_path_for_base_url(&second.api_base_url)
.expect("second credential path")
.file_name()
.expect("second credential filename"),
);
write_private_external_editor_api_credentials_at(&second_path, &second)
.expect("write second private credential");
let error = match unique_private_external_editor_api_credentials_at(&directory) {
Err(error) => error,
Ok(_) => panic!("multiple origins must be ambiguous"),
};
assert!(error.contains("多个陶泥儿服务器"));
}
#[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 credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = root
.path()
.join("config")
.join("genarrative")
.join(credential_file_name);
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");
}
#[cfg(windows)]
#[test]
fn existing_current_user_credential_directory_is_tightened_before_remote_creation() {
let root = tempfile::tempdir().expect("temp dir");
let parent = root.path().join("config").join("genarrative");
fs::create_dir_all(&parent).expect("create existing credential directory");
initialize_windows_game_creator_directory_owner_for_current_user(&parent)
.expect("initialize current-user owner before inherited ACL fixture");
let status = std::process::Command::new("icacls.exe")
.arg(&parent)
.arg("/inheritance:e")
.status()
.expect("run inherited ACL fixture command");
assert!(status.success(), "enable inherited ACL fixture");
assert!(
secure_windows_game_creator_path_for_current_user(&parent, true, false).is_err(),
"fixture must reproduce the inherited ACL rejection"
);
let credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = parent.join(credential_file_name);
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("current-user directory should be tightened locally before remote creation");
secure_windows_game_creator_path_for_current_user(&parent, true, false)
.expect("prepared existing directory must be private");
}
#[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());
}
#[tokio::test]
async fn direct_runtime_platform_account_mode_does_not_install_private_override() {
if editor_api_mode() != EditorApiMode::PlatformAccount {
return;
}
let override_active = with_direct_editor_api_credentials(async {
Ok::<_, String>(external_editor_api_credentials_override_is_active())
})
.await
.expect("platform account operation should not require a private key");
assert!(!override_active);
assert!(!external_editor_api_credentials_override_is_active());
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) -> String {
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]);
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[tokio::test]
async fn external_asset_transfer_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind transfer fixture");
let base_url = format!(
"http://{}",
listener.local_addr().expect("transfer address")
);
let upload_url = url::Url::parse(&format!("{base_url}/upload")).expect("upload URL");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept transfer request");
let request = read_asset_test_request(&mut stream);
stream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write transfer response");
request
});
let client = build_external_asset_download_client(&upload_url, &base_url, true)
.await
.expect("build external asset transfer client");
let response = client
.post(upload_url)
.body("fixture-upload")
.send()
.await
.expect("send transfer request");
let request = server.join().expect("join transfer fixture");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!request
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
}
#[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("下载预算"));
}
#[tokio::test]
async fn canvas_download_revalidates_session_after_signed_url_before_media_get() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind fenced download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
request_sender
.send(read_asset_test_request(&mut signing))
.expect("capture signing request");
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");
listener
.set_nonblocking(true)
.expect("set fenced fixture nonblocking");
let deadline = std::time::Instant::now() + Duration::from_millis(200);
while std::time::Instant::now() < deadline {
match listener.accept() {
Ok((mut media, _)) => {
request_sender
.send(read_asset_test_request(&mut media))
.expect("capture forbidden media request");
break;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("accept fenced media request: {error}"),
}
}
});
let mut fence_calls = 0_u8;
let error = match resolve_canvas_resource_download_with_limit_route_and_fence(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
1024,
"/api/external/v1/assets/read-url",
|| {
fence_calls = fence_calls.saturating_add(1);
if fence_calls == 2 {
Err("session-switched-after-read-url".to_string())
} else {
Ok(())
}
},
)
.await
{
Err(error) => error,
Ok(_) => panic!("session switch after signed URL must stop before media GET"),
};
server.join().expect("join fenced download fixture");
assert_eq!(error, "session-switched-after-read-url");
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(requests.len(), 1, "media GET is forbidden after switch");
assert!(requests[0].starts_with("GET /api/external/v1/assets/read-url?"));
}
}