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
- 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,恢复编译期穷举保护 - 技术方案:登记边界同口径改为描述命令边界解析
3051 lines
116 KiB
Rust
3051 lines
116 KiB
Rust
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, ®istered.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, ®istered.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, ®istered.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, ®istered.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?"));
|
||
}
|
||
}
|