Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs
T
lhk229 65d7a57eb7
Project CI / Frontend tests (push) Successful in 4m0s
Project CI / Repository checks (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 8m40s
Project CI / Native shell tests (push) Successful in 17m56s
Project CI / Frontend tests (pull_request) Successful in 5m16s
Project CI / Repository checks (pull_request) Successful in 6m57s
Project CI / Backend tests (pull_request) Successful in 6m31s
Project CI / Native shell tests (pull_request) Successful in 16m57s
AGC对主站的请求添加header (#241)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/241
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-02 14:39:01 +08:00

2401 lines
90 KiB
Rust

use super::*;
use sha2::{Digest as _, Sha256};
use std::future::Future;
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024;
const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)";
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str =
"private-external-editor-credential-storage-preparation-failed";
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str =
"private-external-editor-credential-persistence-failed";
/// A task-scoped External Editor credential used only by the direct Codex
/// runtime. It never changes the GUI account session and deliberately keeps
/// the key private: callers may use it to make authenticated requests but may
/// not format or serialize this value for diagnostics.
#[derive(Clone)]
pub(crate) struct ExternalEditorApiCredentials {
api_base_url: String,
api_key: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PrivateExternalEditorApiKeyFile {
api_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
}
tokio::task_local! {
static EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE: ExternalEditorApiCredentials;
}
fn active_external_editor_api_credentials() -> Option<ExternalEditorApiCredentials> {
EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE
.try_with(Clone::clone)
.ok()
}
pub(crate) async fn with_external_editor_api_credentials<T>(
credentials: ExternalEditorApiCredentials,
operation: impl Future<Output = T>,
) -> T {
EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE
.scope(credentials, operation)
.await
}
/// 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()
}
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",
media_type,
"upload",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
}
pub(crate) fn register_local_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
source_kind: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let absolute_path = resolve_local_project_path(root, local_path)?;
let metadata = fs::metadata(&absolute_path)
.map_err(|error| format!("读取资产文件失败:{}: {error}", absolute_path.display()))?;
if !metadata.is_file() {
return Err("只能登记文件资产".to_string());
}
let id_prefix = if source_kind.is_empty() {
"generated"
} else {
source_kind
};
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
}
pub(crate) fn import_canvas_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
canvas_project_id: &str,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
prompt: Option<String>,
model: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if resource_id.is_none() && asset_object_id.is_none() {
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
}
register_local_asset_at(
root,
local_path,
kind,
media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id,
asset_object_id,
task_id,
prompt,
model,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
}
pub(crate) fn import_canvas_export_at(
root: &Path,
export_path: &Path,
canvas_project_id: &str,
) -> Result<ImportCanvasExportResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if export_path.as_os_str().is_empty() {
return Err("画板导出 ZIP 路径不能为空".to_string());
}
if !export_path.is_absolute() {
return Err("画板导出 ZIP 路径必须是绝对路径".to_string());
}
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()
.unwrap_or("canvas-resource"),
&download.media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id: Some(resource_id),
asset_object_id: json_string_field(resource, "assetObjectId"),
task_id: json_string_field(resource, "taskId"),
prompt: json_string_field(resource, "actualPrompt")
.or_else(|| json_string_field(resource, "prompt")),
model: json_string_field(resource, "model"),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?);
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.project_sync",
"canvasProjectId": canvas_project_id,
"importRoot": import_root,
"importedCount": assets.len(),
}),
)?;
Ok(SyncCanvasProjectAssetsResult {
canvas_project_id: canvas_project_id.to_string(),
import_root,
imported_count: assets.len(),
assets,
})
}
pub(crate) struct CanvasResourceDownload {
pub(crate) bytes: Vec<u8>,
pub(crate) media_type: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CanvasImageFormat {
Png,
Jpeg,
Webp,
Gif,
}
impl CanvasImageFormat {
fn from_media_type(media_type: &str) -> Option<Self> {
match media_type {
"image/png" => Some(Self::Png),
"image/jpeg" | "image/jpg" => Some(Self::Jpeg),
"image/webp" => Some(Self::Webp),
"image/gif" => Some(Self::Gif),
_ => None,
}
}
fn from_source_hint(source_hint: Option<&str>) -> Option<Self> {
let source = source_hint?
.split(['?', '#'])
.next()
.unwrap_or_default()
.to_ascii_lowercase();
match Path::new(&source)
.extension()
.and_then(|extension| extension.to_str())
{
Some("png") => Some(Self::Png),
Some("jpg" | "jpeg") => Some(Self::Jpeg),
Some("webp") => Some(Self::Webp),
Some("gif") => Some(Self::Gif),
_ => None,
}
}
fn media_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Gif => "image/gif",
}
}
fn matches_magic(self, bytes: &[u8]) -> bool {
match self {
Self::Png => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
Self::Jpeg => bytes.starts_with(&[0xff, 0xd8, 0xff]),
Self::Webp => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP",
Self::Gif => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
}
}
}
fn validate_canvas_downloaded_asset_content(
source_hint: Option<&str>,
image_source: bool,
media_type: &str,
bytes: &[u8],
) -> Result<(), String> {
let normalized_media_type = media_type
.split(';')
.next()
.unwrap_or(media_type)
.trim()
.to_ascii_lowercase();
let declared_format = CanvasImageFormat::from_media_type(&normalized_media_type);
let hinted_format = CanvasImageFormat::from_source_hint(source_hint);
if declared_format
.zip(hinted_format)
.is_some_and(|(declared, hinted)| declared != hinted)
{
return Err("画板图片响应格式与资源路径不一致".to_string());
}
if let Some(format) = declared_format.or(hinted_format) {
if !format.matches_magic(bytes) {
return Err(format!("画板图片内容与 {} 格式不匹配", format.media_type()));
}
} else if normalized_media_type.starts_with("image/") || image_source {
return Err("画板图片响应缺少可校验的受支持格式".to_string());
}
Ok(())
}
pub(crate) async fn resolve_canvas_resource_download(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit(
client,
api_base_url,
api_key,
resource,
20 * 1024 * 1024,
)
.await
}
fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool {
let Ok(api_base_url) = url::Url::parse(api_base_url) else {
return false;
};
url.scheme() == api_base_url.scheme()
&& url.host_str() == api_base_url.host_str()
&& url.port_or_known_default() == api_base_url.port_or_known_default()
}
fn external_asset_host_is_private(host: &str) -> bool {
let host = host.trim_start_matches('[').trim_end_matches(']');
if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
return true;
}
let Ok(address) = host.parse::<std::net::IpAddr>() else {
return false;
};
match address {
std::net::IpAddr::V4(address) => {
let [first, second, ..] = address.octets();
address.is_private()
|| address.is_loopback()
|| address.is_link_local()
|| address.is_broadcast()
|| address.is_unspecified()
|| address.is_multicast()
|| first == 0
|| (first == 100 && (64..=127).contains(&second))
|| (first == 198 && (18..=19).contains(&second))
}
std::net::IpAddr::V6(address) => {
address.is_loopback()
|| address.is_unspecified()
|| address.is_unique_local()
|| address.is_unicast_link_local()
|| address.is_multicast()
|| address
.to_ipv4_mapped()
.is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string()))
}
}
}
fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool {
match address {
std::net::IpAddr::V4(address) => {
let [first, second, ..] = address.octets();
first == 198 && (18..=19).contains(&second)
}
std::net::IpAddr::V6(_) => false,
}
}
fn external_asset_resolved_addresses_are_safe(
addresses: &[std::net::SocketAddr],
came_from_stable_reference: bool,
) -> bool {
if addresses
.iter()
.all(|address| !external_asset_host_is_private(&address.ip().to_string()))
{
return true;
}
// Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。
// 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外;
// 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。
came_from_stable_reference
&& !addresses.is_empty()
&& addresses
.iter()
.all(|address| external_asset_ip_is_proxy_benchmark(address.ip()))
}
pub(crate) fn validate_external_asset_download_url(
value: &str,
api_base_url: &str,
_came_from_stable_reference: bool,
) -> Result<url::Url, String> {
let url = url::Url::parse(value).map_err(|error| format!("画板资产下载地址无效:{error}"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err("画板资产下载地址只允许 HTTP(S)".to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("画板资产下载地址不能包含用户凭据".to_string());
}
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
// 配置中的 External Editor 本身可以是 localhost;同源媒体仍停留在这条已授权
// 边界内。任何跨 origin 的私网地址均拒绝,且下载客户端不跟随重定向。
if external_asset_host_is_private(host) && !external_asset_url_same_origin(&url, api_base_url) {
return Err("画板资产下载地址指向本机或私有网络,已拒绝请求".to_string());
}
Ok(url)
}
pub(crate) async fn build_external_asset_download_client(
url: &url::Url,
api_base_url: &str,
came_from_stable_reference: bool,
) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none());
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
let host_is_literal_or_local = host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.is_ok()
|| host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost");
if !host_is_literal_or_local && !external_asset_url_same_origin(url, api_base_url) {
let lookup_host = host.to_string();
let lookup_port = url
.port_or_known_default()
.ok_or_else(|| "画板资产下载地址缺少有效端口".to_string())?;
let addresses = tokio::task::spawn_blocking(move || {
std::net::ToSocketAddrs::to_socket_addrs(&(lookup_host.as_str(), lookup_port))
.map(|addresses| addresses.collect::<Vec<_>>())
})
.await
.map_err(|_| "解析画板资产下载域名的任务异常".to_string())?
.map_err(|error| format!("解析画板资产下载域名失败:{error}"))?;
if addresses.is_empty() {
return Err("画板资产下载域名没有可用地址".to_string());
}
if !external_asset_resolved_addresses_are_safe(&addresses, came_from_stable_reference) {
return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string());
}
builder = builder.resolve_to_addrs(host, &addresses);
}
builder
.build()
.map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))
}
pub(crate) async fn resolve_canvas_resource_download_with_limit(
_client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
max_bytes: usize,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
_client,
api_base_url,
api_key,
resource,
max_bytes,
&resolve_platform_editor_api_route("/api/external/v1/assets/read-url"),
)
.await
}
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)
}
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> &'static str {
let layer_type = layer.visible.layer_type.as_str();
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
return "animation";
}
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
return "audio";
}
if contains_any(layer_type, &["角色"]) {
return "character";
}
if contains_any(layer_type, &["场景", "背景"]) {
return "scene";
}
if contains_any(layer_type, &["UI", "界面", "图标"]) {
return "ui";
}
"asset"
}
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
if file.starts_with("sequences/") {
return "application/vnd.genarrative.image-sequence";
}
match Path::new(file)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("ogg") => "audio/ogg",
Some("aac") => "audio/aac",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("mov") => "video/quicktime",
Some("json") => "application/json",
Some("txt") => "text/plain",
_ => "application/octet-stream",
}
}
pub(crate) fn build_canvas_project_url(
editor_base_url: Option<&str>,
canvas_project_id: Option<&str>,
) -> Result<String, String> {
let base_url = editor_base_url
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_EDITOR_BASE_URL);
let mut url =
tauri::Url::parse(base_url).map_err(|_| "画板地址必须是本地 HTTP 地址".to_string())?;
if url.scheme() != "http" {
return Err("画板地址必须使用本地 HTTP".to_string());
}
match url.host_str() {
Some("127.0.0.1" | "localhost") => {}
_ => return Err("画板地址只能指向本机编辑器".to_string()),
}
url.set_path("/editor/canvas");
url.set_query(None);
url.set_fragment(None);
if let Some(project_id) = canvas_project_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if project_id.chars().any(char::is_control) {
return Err("画板项目 ID 不能包含控制字符".to_string());
}
url.query_pairs_mut()
.append_pair("projectid", project_id)
.finish();
}
Ok(url.to_string())
}
pub(crate) fn register_local_asset_entry(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let manifest_path = root.join(".agent/manifest.json");
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
} else {
media_type
};
let mut source_for_record = source.clone();
source_for_record.prompt = None;
let (id, record_type) = mutate_manifest_at(root, |manifest| {
if let Some(existing) = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == normalized_path)
{
existing.kind = kind.to_string();
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update"))
} else {
let id = format!(
"{id_prefix}-{}-{}",
unix_millis(),
manifest.assets.len() + 1
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: kind.to_string(),
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source,
});
Ok((id, "asset.register"))
}
})?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn 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?"));
}
}