From d222aad2ec2c1e071fe847e2ad42e45ea147fa5e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:24:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E7=B4=A0=E6=9D=90=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8F=90=E4=BA=A4=E5=89=8D=E9=A2=84=E6=A3=80=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=8F=82=E8=80=83=E9=93=BE=E8=B7=AF=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考解析前增加纯本地预检:清单归属、受控路径与文件、媒体类型、可解码位图全部通过后才允许产生任何上传副作用,避免第一张参考已上传、第二张坏图才失败 明确拒绝 SVG 等矢量参考,媒体类型或文件扩展名任一命中即拒,本次不做矢量转换,坏图同样在本地失败关闭 asset.upload 门禁提前到预检阶段,显式拒绝该命令的项目连第一个上传凭证都不会签发 预检与实际上传共用同一份本地判据 helper,不新建平行流程,上传侧仍按同一判据重新读取与解码 补参考合同与恢复的账本用例:icon-spec 带用户参考、game-background 规范图加用户参考、图集超限、空白引用写入失败 补提交侧用例:拒绝路径、未登记、非图片、缺失文件、超限、SVG 与坏图,并断言零上传凭证与零生成 POST,反向验证同一张合格参考单独提交确实会签发凭证 补 asset.upload 拒绝用例,以及清单校验与 Direct 身份校验接受用户参考、拒绝用户参考顶替规范引用的覆盖 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 72 +++ .../src/agent/generation/canvas_generation.rs | 475 ++++++++++++++++-- .../src-tauri/src/tests/project.rs | 455 +++++++++++++++++ 3 files changed, 952 insertions(+), 50 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 87e4b3174..7a0b84b13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -9808,6 +9808,78 @@ mod tests { ); } + #[test] + fn direct_taonier_art_package_accepts_manifest_user_references() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + assert!(direct_taonier_art_package_is_valid(root.path())); + + // 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。 + mutate_manifest_at(root.path(), |manifest| { + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "user-reference-2".to_string(), + ]; + let background = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH) + .expect("background asset"); + background + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("apply user references to the art base"); + assert!( + direct_taonier_art_package_is_valid(root.path()), + "user references must not invalidate the art package" + ); + + // 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("add an extra spritesheet reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "art spritesheet must reject extra user references" + ); + + // 用户参考不能顶替图集的规范前置。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + Ok(()) + }) + .expect("replace the spritesheet canonical reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a user reference must not replace the art spritesheet canonical spec" + ); + } + #[test] fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index a544ba9db..b298c0829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1720,6 +1720,24 @@ async fn canonical_art_spec_reference_at( access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { + let (manifest_project_id, source) = canonical_art_spec_manifest_entry_at(root)?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest_project_id, + expected_canvas_project_id, + &source, + ) + .await +} + +/// 当前项目已登记的规范图清单条目(`assets/art-spec.png` 且 `icon-spec`)。 +/// +/// 提交前预检与实际上传共用这一份归属判据:路径、远端 ID 或其它项目素材都不能冒充规范图。 +fn canonical_art_spec_manifest_entry_at( + root: &Path, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets @@ -1733,50 +1751,75 @@ async fn canonical_art_spec_reference_at( .ok_or_else(|| { "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() - })?; - upload_manifest_asset_remote_reference_at( - root, - client, - access, - &manifest.project_id, - expected_canvas_project_id, - source, - ) - .await + })? + .clone(); + Ok((manifest.project_id, source)) } -/// 用户参考素材(当前项目 manifest `assets[].id`)解析当前账号的远端资源 ID。 +/// 用户参考素材(当前项目 manifest `assets[].id`)的清单归属解析。 /// -/// 只接受**当前项目**清单里的图片素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 只接受**当前项目**清单里的素材:路径、远端 resourceId、其它项目的素材都不在清单里, /// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 -async fn manifest_asset_remote_reference_at( +fn manifest_asset_reference_entry_at( root: &Path, - client: &reqwest::Client, - access: &ExternalEditorBindingAccess<'_>, - expected_canvas_project_id: &str, asset_id: &str, -) -> Result { +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets .iter() .find(|asset| asset.id == asset_id) - .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))?; + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 参考素材的**纯本地**校验与读取:清单身份由调用方先证明,这里只管受控路径 → 文件存在 → +/// 媒体类型 → 可解码位图。 +/// +/// 不做任何远端调用;提交前预检与实际上传读同一份判据。SVG 等矢量格式必须在**任何上传之前** +/// 明确拒绝:上游 `image/*` 筛选会放进 SVG,而位图解码器必定失败;本次不做隐式转换,也不允许 +/// 「第一张参考已上传、第二张坏图才失败」的半完成副作用。 +fn read_validated_platform_art_reference_at( + root: &Path, + source: &GameCreationAppAssetManifestEntry, +) -> Result<(Vec, image::DynamicImage), String> { if !source.media_type.starts_with("image/") { return Err(format!( "参考素材必须是图片,不能引用 {}:{}", - source.media_type, asset_id + source.media_type, source.id )); } - upload_manifest_asset_remote_reference_at( - root, - client, - access, - &manifest.project_id, - expected_canvas_project_id, - source, - ) - .await + if platform_art_reference_source_is_vector(source) { + return Err(format!( + "参考素材不支持 SVG 等矢量格式,请改用 PNG/JPEG 位图:{}", + source.id + )); + } + let source_path = resolve_local_project_path(root, &source.local_path)?; + if !source_path.is_file() { + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); + } + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + Ok((bytes, decoded)) +} + +/// SVG 等矢量格式:媒体类型或文件扩展名任一命中都算矢量,避免只靠声明类型漏判。 +fn platform_art_reference_source_is_vector(source: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = source.media_type.trim().to_ascii_lowercase(); + let media_type = media_type.split(';').next().unwrap_or_default().trim(); + if matches!(media_type, "image/svg+xml" | "image/svg") { + return true; + } + Path::new(source.local_path.trim()) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "svg" | "svgz")) } /// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 @@ -1796,16 +1839,8 @@ async fn upload_manifest_asset_remote_reference_at( // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 enforce_project_permission_policy(root, "asset.upload")?; let file_name = platform_art_reference_upload_file_name(source); - let source_path = resolve_local_project_path(root, &source.local_path)?; - if !source_path.is_file() { - return Err(format!( - "参考素材 {} 不存在;请重新登记后再引用", - source.local_path - )); - } - let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; - let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + // 与提交前预检共用同一份本地判据:受控路径、文件存在、媒体类型(含 SVG 拒绝)与可解码性。 + let (bytes, decoded) = read_validated_platform_art_reference_at(root, source)?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -2050,26 +2085,48 @@ async fn resolve_platform_art_generation_references_at( &options.asset_kind, &options.reference_asset_ids, )?; - let canonical = - if platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind) { - Some( - canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) - .await?, - ) - } else { - None - }; + let requires_canonical = + platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, + // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 + if requires_canonical || !user_reference_asset_ids.is_empty() { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` + // 门禁,显式拒绝该命令的项目在本地就失败关闭,连第一个上传凭证都不会签发。 + enforce_project_permission_policy(root, "asset.upload")?; + } + let canonical_source = requires_canonical + .then(|| canonical_art_spec_manifest_entry_at(root)) + .transpose()?; + if let Some((_, source)) = canonical_source.as_ref() { + let _ = read_validated_platform_art_reference_at(root, source)?; + } + let mut user_reference_sources = Vec::with_capacity(user_reference_asset_ids.len()); + for asset_id in &user_reference_asset_ids { + let (manifest_project_id, source) = manifest_asset_reference_entry_at(root, asset_id)?; + let _ = read_validated_platform_art_reference_at(root, &source)?; + user_reference_sources.push((manifest_project_id, source)); + } + // 预检全部通过后才允许产生远端副作用。 + let canonical = if requires_canonical { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; let mut ordered = Vec::new(); if let Some(reference) = canonical.as_ref() { ordered.push(reference.clone()); } - for asset_id in &user_reference_asset_ids { - let reference = manifest_asset_remote_reference_at( + for (manifest_project_id, source) in &user_reference_sources { + let reference = upload_manifest_asset_remote_reference_at( root, client, access, + manifest_project_id, expected_canvas_project_id, - asset_id, + source, ) .await?; if !ordered.iter().any(|existing| existing == &reference) { @@ -12616,6 +12673,324 @@ mod canvas_generation_tests { ); } + /// 参考素材 id 入参只按当前项目清单形状收口:路径、远端资源 ID、控制字符与超限都在这里拒绝。 + #[test] + fn reference_asset_ids_are_normalized_and_rejected_before_any_remote_call() { + let ids = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + // 去重保持给出顺序,空白项直接丢弃。 + assert_eq!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) + .expect("normalize icon-spec references"), + ids(&["b", "a"]) + ); + // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + "hero\u{7}", + &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), + ] { + assert!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + "{rejected} 不能被当成参考素材 id" + ); + } + // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 + assert_eq!( + normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e"]) + ) + .expect("five references without a canonical spec") + .len(), + 5 + ); + assert!(normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e", "f"]) + ) + .is_err()); + assert_eq!( + normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) + .expect("four user references with a canonical spec") + .len(), + 4 + ); + assert!(normalize_platform_art_reference_asset_ids( + "ui-prototype", + &ids(&["a", "b", "c", "d", "e"]) + ) + .is_err()); + // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() + ); + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) + .expect("spritesheet without user references") + .is_empty() + ); + } + + /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 + #[test] + fn reference_contract_matches_the_submission_limits_per_kind() { + let references = |count: usize| { + (0..count) + .map(|index| format!("reference-{index}")) + .collect::>() + }; + // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 + assert!(platform_art_runtime_references_match_request_contract( + &[], + "icon-spec" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "icon-spec" + )); + // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 + for kind in ["ui-prototype", "game-background"] { + assert!( + !platform_art_runtime_references_match_request_contract(&[], kind), + "{kind} 必须有规范图前置" + ); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + kind + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + kind + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + kind + )); + } + // 图集:恰好一项,多一项都不算同一份请求合同。 + assert!(!platform_art_runtime_references_match_request_contract( + &[], + "art-spritesheet" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + "art-spritesheet" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(2), + "art-spritesheet" + )); + // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &[" ".to_string()], + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(1), + "unknown-kind" + )); + } + + /// 保留账本的恢复校验必须接受与提交同一套参考合同。 + /// + /// 旧实现把 `icon-spec` 写死成「引用必须为空」、把有规范前置的生成写死成「恰好 1 项」, + /// 带用户参考的合法账本会被判成身份不符而恢复失败。这里直接写真实账本再读回校验。 + #[test] + fn retained_stage_recovery_accepts_the_same_reference_contract_as_submission() { + fn write_retained_stage_result( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> Result { + let context = PlatformArtGenerationRuntimeContext { + agent_id: "manual-canvas-asset-generate".to_string(), + task_id: "retained-reference-contract-task".to_string(), + session_id: "retained-reference-contract-session".to_string(), + run_id: run_id.to_string(), + source: "test".to_string(), + action_id: format!("retained-reference-contract-{run_id}"), + action_fingerprint: format!("retained-reference-contract-v1:{run_id}"), + }; + let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let frozen_platform_session = frozen_platform_session + .ok_or_else(|| "保留账本测试必须使用平台账号".to_string())?; + let access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session)?; + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "retained-reference-contract-canvas", + "保留账本参考合同", + &request_body, + &access, + )?; + if !created { + return Err("保留账本测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(context) + } + + fn write_retained_stage( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> PlatformArtGenerationRuntimeContext { + write_retained_stage_result(root, run_id, endpoint, request_body) + .unwrap_or_else(|error| panic!("write retained reference contract ledger: {error}")) + } + + let temporary = tempfile::tempdir().expect("create retained reference contract project"); + let root = temporary.path(); + init_local_game_project_at(root, "retained-reference-contract", "参考合同测试") + .expect("init retained reference contract project"); + let _platform_session = crate::platform_session::install_test_platform_session( + "retained-reference-contract-user", + "retained-reference-contract-key", + "http://127.0.0.1:9", + ); + + // 根素材带用户参考:旧实现要求引用为空,这里必须被认成合法账本。 + let icon_spec = write_retained_stage( + root, + "run-icon-spec-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": ["user-reference-1", "user-reference-2"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &icon_spec, + "icon-spec", + ) + .expect("read icon-spec ledger with user references") + ); + + // 有规范前置的生成带规范图加用户参考:旧实现要求恰好 1 项,这里必须被认成合法账本。 + let background = write_retained_stage( + root, + "run-background-canonical-and-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "game-background", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "background" } }, + "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &background, + "game-background", + ) + .expect("read game-background ledger with a canonical and a user reference") + ); + + // 图集仍只接受唯一规范引用。 + let spritesheet = write_retained_stage( + root, + "run-spritesheet-canonical-reference", + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "referenceId": "resource-icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "art" } }, + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &spritesheet, + "art-spritesheet", + ) + .expect("read art-spritesheet ledger with the canonical reference") + ); + + // 超出总上限的参考集合不能被当成同一份请求合同。 + let over_limit = write_retained_stage( + root, + "run-icon-spec-over-limit", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [ + "user-reference-1", + "user-reference-2", + "user-reference-3", + "user-reference-4", + "user-reference-5", + "user-reference-6", + ], + }), + ); + assert!( + !retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &over_limit, + "icon-spec", + ) + .expect("read over limit icon-spec ledger") + ); + + // 空白引用连账本都写不进去:写入后的读回校验必须直接失败关闭。 + let blank = write_retained_stage_result( + root, + "run-icon-spec-blank-reference", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [" "], + }), + ) + .expect_err("blank references must not be persisted into the ledger"); + assert!(blank.contains("引用资源 ID 无效"), "{blank}"); + } + fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index fb0915dce..a5a201fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -378,6 +378,78 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + // 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入, + // 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。 + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + for task_id in ["art-director", "design-foundation"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}")); + } + + // 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "resource-icon-spec".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("a leading user reference must not replace the canonical spec") + .contains("未绑定当前统一视觉规范图的本地内容身份") + ); + + // 只接受单规范引用的图集不接受额外用户参考。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("art spritesheet must reject extra user references") + .contains("未精确引用当前统一视觉规范图") + ); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = Vec::new(); + let art_spec_path = root.join("assets/art-spec.png"); let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) @@ -1019,6 +1091,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { "canvas-project-1", ); } + if root.join("assets/user-reference.png").is_file() { + bind_canvas_visual_asset_fixture_to_current_editor( + root, + "assets/user-reference.png", + "canvas-project-1", + ); + } request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); @@ -1087,9 +1166,385 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""kind":"ui-design""#)); assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 + register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image"); + let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); + let icon_spec_config_dir = unique_project_path(); + let icon_spec_with_reference = capture_generation_request( + &root, + &icon_spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/icon-spec-custom.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "带用户参考的图标规范".to_string(), + reference_asset_ids: vec![ + user_reference_asset_id.clone(), + user_reference_asset_id.clone(), + ], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations ")); + // 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。 + assert!( + icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#), + "{icon_spec_with_reference}" + ); + + let ui_with_reference_config_dir = unique_project_path(); + let ui_with_reference = capture_generation_request( + &root, + &ui_with_reference_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype-custom.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "ui-prototype".to_string(), + asset_label: "带用户参考的界面原型图".to_string(), + reference_asset_ids: vec![user_reference_asset_id.clone()], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + // 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。 + assert!( + ui_with_reference + .contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#), + "{ui_with_reference}" + ); + fs::remove_dir_all(root).ok(); fs::remove_dir_all(spec_config_dir).ok(); fs::remove_dir_all(ui_config_dir).ok(); + fs::remove_dir_all(icon_spec_config_dir).ok(); + fs::remove_dir_all(ui_with_reference_config_dir).ok(); +} + +/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。 +fn is_reference_upload_ticket_request(request: &str) -> bool { + request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") +} + +/// 图片生成提交:同样两种前缀都要算。 +fn is_image_generation_request(request: &str) -> bool { + request.starts_with("POST /api/editor/images/generations ") + || request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") + || request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") +} + +/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。 +fn manifest_asset_id(root: &Path, local_path: &str) -> String { + read_manifest_for_project(root) + .expect("read manifest for asset id") + .assets + .into_iter() + .find(|asset| asset.local_path == local_path) + .unwrap_or_else(|| panic!("registered asset is present: {local_path}")) + .id +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + // 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。 + let canvas_base_url = + spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-guard-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference guard config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference guard config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-guard", "参考素材门禁") + .expect("init reference guard project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow reference guard generation"); + let options = + |asset_kind: &str, reference_asset_ids: Vec| PlatformArtAssetGenerationOptions { + output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), + asset_kind: asset_kind.to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids, + ..PlatformArtAssetGenerationOptions::default() + }; + async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { + request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) + .await + .expect_err("reference selection must fail closed") + } + let canvas_source = || 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(), + }; + + // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 + let error = reject( + &root, + options("art-spritesheet", vec!["user-asset".to_string()]), + ) + .await; + assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); + + // 路径、URL 等形状不是素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + ] { + let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); + } + + // 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。 + let error = reject( + &root, + options( + "icon-spec", + vec!["editor-resource-from-older-account".to_string()], + ), + ) + .await; + assert!( + error.contains("参考素材不在当前项目已登记清单中"), + "{error}" + ); + + // 非图片素材不能当参考。 + fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir"); + fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture"); + register_local_asset_at( + &root, + "assets/document.json", + "image", + "application/json", + "canvas", + canvas_source(), + ) + .expect("register non image reference fixture"); + let error = reject( + &root, + options( + "icon-spec", + vec![manifest_asset_id(&root, "assets/document.json")], + ), + ) + .await; + assert!(error.contains("参考素材必须是图片"), "{error}"); + + // 已登记但本地文件缺失的素材不能被引用。 + register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image"); + let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); + fs::remove_file(root.join("assets/missing-reference.png")) + .expect("remove missing reference fixture file"); + let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); + + // 超过总上限:无规范前置最多 5 张。 + let error = reject( + &root, + options( + "icon-spec", + (0..6).map(|index| format!("reference-{index}")).collect(), + ), + ) + .await; + assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); + + // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + fs::write( + root.join("assets/vector-reference.svg"), + b"", + ) + .expect("write svg reference fixture"); + register_local_asset_at( + &root, + "assets/vector-reference.svg", + "image", + "image/svg+xml", + "canvas", + canvas_source(), + ) + .expect("register svg reference fixture"); + let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), vector_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + // 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。 + fs::write( + root.join("assets/mislabeled-reference.svg"), + valid_test_png_bytes(), + ) + .expect("write mislabeled svg reference fixture"); + register_local_asset_at( + &root, + "assets/mislabeled-reference.svg", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register mislabeled svg reference fixture"); + let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), mislabeled_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + fs::write(root.join("assets/broken-reference.png"), b"not-a-png") + .expect("write broken reference fixture"); + register_local_asset_at( + &root, + "assets/broken-reference.png", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register broken reference fixture"); + let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), broken_asset_id], + ), + ) + .await; + assert!(error.contains("不是可解析图片"), "{error}"); + + // 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。 + // 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。 + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + } + + // 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。 + let differential = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &options("icon-spec", vec![plain_reference_asset_id.clone()]), + ) + .await; + let mut upload_ticket_attempted = false; + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) { + if is_reference_upload_ticket_request(&request) { + upload_ticket_attempted = true; + } + } + assert!( + upload_ticket_attempted, + "the same single reference must attempt an upload ticket: {differential:?}" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_upload_permission_gate_fails_closed_before_any_upload() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-permission-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference permission config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference permission config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-permission", "参考上传门禁") + .expect("init reference permission project"); + // 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。 + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.upload".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny reference upload"); + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + + let error = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &PlatformArtAssetGenerationOptions { + output_path: Some("assets/reference-permission.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids: vec![plain_reference_asset_id], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await + .expect_err("a denied asset.upload must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:asset.upload"), + "{error}" + ); + + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)]