use std::io::Cursor; use axum::{ Json, extract::{Extension, Multipart, Path, State}, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::{IntoResponse, Response}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use module_assets::template_library::{ MAX_TEMPLATE_IMPORT_BATCH, TemplateCover, TemplateDomainError, TemplateEdit, TemplateImport, list_templates, prepare_template_edit, prepare_template_import, }; use platform_oss::template_library::{TemplateLibraryStore, TemplateStoreError}; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ AdminAgcTemplateCoverInput, AdminAgcTemplateListResponse, AdminAgcTemplatePayload, AdminImportAgcTemplateItem, AdminImportAgcTemplateResult, AdminImportAgcTemplatesManifest, AdminImportAgcTemplatesResponse, AdminUpdateAgcTemplateRequest, }; use std::collections::{BTreeMap, BTreeSet}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ admin::AuthenticatedAdmin, api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; const MAX_COVER_BYTES: usize = 5 * 1024 * 1024; const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024; /// 后台上传:单批数量、单包体积与解压后的容量上限(路由层再叠加请求体总上限)。 const MAX_IMPORT_MANIFEST_BYTES: usize = 256 * 1024; const MAX_IMPORT_ZIP_BYTES: usize = 64 * 1024 * 1024; const MAX_IMPORT_ARCHIVE_FILES: usize = 4096; const MAX_IMPORT_ARCHIVE_FILE_BYTES: u64 = 256 * 1024 * 1024; const MAX_IMPORT_ARCHIVE_TOTAL_BYTES: u64 = 512 * 1024 * 1024; pub(crate) const AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES: usize = 200 * 1024 * 1024; const PUBLIC_BASE: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/"; fn fingerprint(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn decode_index(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|_| { AppError::from_status(StatusCode::BAD_GATEWAY).with_message("模板库清单格式无效") }) } fn public_url(key: &str) -> String { let mut url = reqwest::Url::parse(PUBLIC_BASE).expect("constant OSS URL"); url.path_segments_mut() .expect("HTTPS path") .extend(key.split('/')); url.to_string() } fn snapshot(bytes: &[u8], writable: bool) -> Result { let entries = list_templates(&decode_index(bytes)?).map_err(map_domain_error)?; Ok(AdminAgcTemplateListResponse { revision: fingerprint(bytes), writable, templates: entries .into_iter() .map(|entry| AdminAgcTemplatePayload { id: entry.id, title: entry.title, summary: entry.summary, tags: entry.tags, runtime: entry.runtime, engine: entry.engine, engine_version: entry.engine_version, template_version: entry.template_version, enabled: entry.enabled, cover_url: public_url(&entry.cover_key), zip_size_bytes: entry.zip_size_bytes, }) .collect(), }) } fn json_snapshot(context: &RequestContext, value: T) -> Response { let mut response = json_success_body(Some(context), value).into_response(); response .headers_mut() .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); response } pub async fn admin_list_agc_templates( State(state): State, Extension(context): Extension, Extension(_admin): Extension, ) -> Result { let store = state.template_library_store(); let bytes = match store { Some(store) => store.read_index().await, None => TemplateLibraryStore::read_public_index().await, } .map_err(map_store_error)?; Ok(json_snapshot(&context, snapshot(&bytes, store.is_some())?)) } struct ValidatedCover { bytes: Vec, content_type: &'static str, extension: &'static str, width: u32, height: u32, } fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result { let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message); if input.data_base64.len() > MAX_COVER_BYTES.div_ceil(3) * 4 { return Err(invalid("封面不能超过 5 MiB")); } let bytes = STANDARD .decode(&input.data_base64) .map_err(|_| invalid("封面编码无效"))?; validate_cover_bytes(&input.content_type, bytes) } /// 与编辑路径共用同一套封面上限与图片校验;上传路径直接给原始字节与声明的 content-type。 fn validate_cover_bytes( declared_content_type: &str, bytes: Vec, ) -> Result { let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message); if bytes.is_empty() || bytes.len() > MAX_COVER_BYTES { return Err(invalid("封面内容为空或超过 5 MiB")); } let format = image::guess_format(&bytes).map_err(|_| invalid("封面不是有效图片"))?; let (content_type, extension) = match format { image::ImageFormat::Png => ("image/png", "png"), image::ImageFormat::Jpeg => ("image/jpeg", "jpg"), image::ImageFormat::WebP => ("image/webp", "webp"), _ => return Err(invalid("封面仅支持 PNG、JPEG 或 WebP")), }; if declared_content_type != content_type { return Err(invalid("封面格式与文件内容不一致")); } let mut reader = image::ImageReader::with_format(Cursor::new(&bytes), format); let mut limits = image::Limits::default(); limits.max_image_width = Some(4096); limits.max_image_height = Some(4096); limits.max_alloc = Some(64 * 1024 * 1024); reader.limits(limits); let decoded = reader .decode() .map_err(|_| invalid("封面损坏或图片尺寸过大"))?; let (width, height) = (decoded.width(), decoded.height()); if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_000_000 { return Err(invalid("封面最多允许 1600 万像素")); } Ok(ValidatedCover { bytes, content_type, extension, width, height, }) } fn document_bytes(value: &Value) -> Result, AppError> { let mut bytes = serde_json::to_vec_pretty(value) .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; bytes.push(b'\n'); if bytes.len() > MAX_DOCUMENT_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库元数据超过大小限制") ); } Ok(bytes) } fn check_revision(bytes: &[u8], expected: &str) -> Result<(), AppError> { if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库版本标识无效") ); } if fingerprint(bytes) != expected.to_ascii_lowercase() { return Err(AppError::from_status(StatusCode::CONFLICT) .with_code("TEMPLATE_LIBRARY_CONFLICT") .with_message("模板库已更新,请刷新后重新编辑")); } Ok(()) } pub async fn admin_update_agc_template( State(state): State, Extension(context): Extension, Extension(_admin): Extension, Path(id): Path, Json(mut input): Json, ) -> Result { let store = state.template_library_store().cloned().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("模板管理未配置可用的存储凭据,当前仅支持查看") })?; let cover = match input.cover.take() { Some(cover) => Some( tokio::task::spawn_blocking(move || validate_cover(cover)) .await .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))??, ), None => None, }; // 接受后的有限写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。 let result = tokio::spawn(async move { update_template(store, id, input, cover).await }) .await .map_err(|_| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("模板保存结果需要核对,请刷新列表;若发布锁仍被占用请联系运维") })??; Ok(json_snapshot(&context, result)) } async fn update_template( store: TemplateLibraryStore, id: String, input: AdminUpdateAgcTemplateRequest, cover: Option, ) -> Result { let mut session = store .begin_publish(uuid::Uuid::new_v4().to_string()) .await .map_err(map_store_error)?; let outcome = async { let current = session.read_index().await.map_err(map_store_error)?; check_revision(¤t, &input.expected_revision)?; let index = decode_index(¤t)?; let entry = list_templates(&index) .map_err(map_domain_error)? .into_iter() .find(|entry| entry.id == id) .ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在") })?; let metadata_bytes = session .read_object(&entry.metadata_key, MAX_DOCUMENT_BYTES) .await .map_err(map_store_error)?; let metadata = decode_index(&metadata_bytes)?; let cover_reference = cover.as_ref().map(|cover| { let sha256 = fingerprint(&cover.bytes); TemplateCover { key: format!( "templates/v1/{id}/sha256/{sha256}/cover.{}", cover.extension ), sha256, width: cover.width, height: cover.height, } }); let updated_at = OffsetDateTime::now_utc() .format(&Rfc3339) .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; let mut prepared = prepare_template_edit( index, metadata, &id, TemplateEdit { title: input.title, summary: input.summary, tags: input.tags, enabled: input.enabled, }, cover_reference.clone(), &updated_at, ) .map_err(map_domain_error)?; let metadata_bytes = document_bytes(&prepared.metadata)?; let metadata_key = format!( "templates/v1/{id}/sha256/{}/template.json", fingerprint(&metadata_bytes) ); prepared .set_metadata_key(&id, &metadata_key) .map_err(map_domain_error)?; let next_index_bytes = document_bytes(&prepared.index)?; if let (Some(cover), Some(reference)) = (cover, cover_reference) { session .put_immutable(&reference.key, cover.bytes, cover.content_type) .await .map_err(map_store_error)?; } session .put_immutable(&metadata_key, metadata_bytes, "application/json") .await .map_err(map_store_error)?; session .commit_index(next_index_bytes.clone()) .await .map_err(map_store_error)?; snapshot(&next_index_bytes, true) } .await; let released = session.finish().await.map_err(map_store_error); match (outcome, released) { (Ok(result), Ok(())) => Ok(result), (Err(error), Ok(())) => Err(error), (_, Err(error)) => Err(error), } } fn import_invalid(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) } /// 模板包内路径:只允许库内相对路径,拒绝绝对路径、盘符、反斜杠、`..` 与空段。 fn safe_import_archive_path(value: &str) -> Result { let trimmed = value.trim_end_matches('/'); if trimmed.is_empty() || trimmed.len() > 256 || trimmed.starts_with(['/', '\\']) || trimmed.contains('\\') || trimmed.contains(':') || trimmed.contains("..") || trimmed .chars() .any(|value| value.is_control() || value.is_whitespace()) || !trimmed .split('/') .all(|part| !part.is_empty() && part != ".") { return Err(format!("模板包条目路径无效:{value}")); } Ok(trimmed.to_string()) } /// 任何层级都不允许出现的条目段:项目身份(`.agent`)与版本库 / 依赖元数据。 const IMPORT_FORBIDDEN_SEGMENTS: [&str; 4] = [".agent", ".git", ".svn", "node_modules"]; /// 只允许出现在模板根目录之外的构建产物与编辑器工作区目录。 const IMPORT_FORBIDDEN_ROOT_SEGMENTS: [&str; 7] = [ "dist", "build", "library", "temp", "local", ".idea", ".vscode", ]; /// 模板作者约定(见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`):这些内容 /// 进了包就会跟着建到用户项目里,因此发布前失败关闭,而不是靠作者自觉。 fn import_entry_path_violation(relative: &str) -> Option { let segments = relative.split('/').collect::>(); for segment in &segments { if IMPORT_FORBIDDEN_SEGMENTS.contains(segment) { return Some(format!("模板包不允许包含 {segment} 目录")); } } if let Some(first) = segments.first() { if IMPORT_FORBIDDEN_ROOT_SEGMENTS.contains(first) { return Some(format!("模板包根目录不允许包含 {first} 目录")); } } None } /// 只做校验、不落盘:确认上传的是合法 zip、条目安全,并包含清单声明的 entry。 fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> { let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) .map_err(|error| format!("模板包不是有效 zip:{error}"))?; if archive.len() == 0 { return Err("模板包没有文件".to_string()); } if archive.len() > MAX_IMPORT_ARCHIVE_FILES { return Err(format!("模板包文件数量超过 {MAX_IMPORT_ARCHIVE_FILES}")); } let mut total_bytes = 0_u64; let mut has_entry = false; for index in 0..archive.len() { let file = archive .by_index(index) .map_err(|error| format!("读取模板包条目失败:{error}"))?; if file .unix_mode() .is_some_and(|mode| mode & 0o170000 == 0o120000) { return Err("模板包不允许包含符号链接".to_string()); } let relative = safe_import_archive_path(file.name())?; if let Some(violation) = import_entry_path_violation(&relative) { return Err(violation); } if relative == entry { has_entry = true; } if file.is_dir() { continue; } if file.size() > MAX_IMPORT_ARCHIVE_FILE_BYTES { return Err(format!("模板包文件超过大小上限:{relative}")); } total_bytes = total_bytes.saturating_add(file.size()); if total_bytes > MAX_IMPORT_ARCHIVE_TOTAL_BYTES { return Err("模板包解压后总大小超过上限".to_string()); } } if !has_entry { return Err(format!("模板包缺少清单声明的 entry:{entry}")); } Ok(()) } /// 上传封面按字节嗅探格式:浏览器给的 part content-type 常是 octet-stream,不能当判据。 fn uploaded_cover_content_type(bytes: &[u8]) -> Result<&'static str, AppError> { match image::guess_format(bytes) { Ok(image::ImageFormat::Png) => Ok("image/png"), Ok(image::ImageFormat::Jpeg) => Ok("image/jpeg"), Ok(image::ImageFormat::WebP) => Ok("image/webp"), _ => Err(import_invalid("封面仅支持 PNG、JPEG 或 WebP")), } } #[derive(Debug)] struct PlannedImport { import: TemplateImport, zip_bytes: Vec, cover_bytes: Vec, cover_content_type: &'static str, } fn plan_import_item( item: AdminImportAgcTemplateItem, files: &BTreeMap>, ) -> Result { let zip_field = item.zip_field.trim().to_string(); let cover_field = item.cover_field.trim().to_string(); if zip_field.is_empty() || cover_field.is_empty() || zip_field == cover_field { return Err(import_invalid(format!( "{} 必须分别提供 ZIP 与封面的文件字段名", item.id ))); } let zip_bytes = files .get(&zip_field) .ok_or_else(|| import_invalid(format!("{} 缺少 ZIP 文件字段 {zip_field}", item.id)))? .clone(); if zip_bytes.len() > MAX_IMPORT_ZIP_BYTES { return Err(import_invalid(format!( "{} 的模板包超过 {} MiB", item.id, MAX_IMPORT_ZIP_BYTES / (1024 * 1024) ))); } validate_import_archive(&zip_bytes, item.entry.trim()) .map_err(|error| import_invalid(format!("{}:{error}", item.id)))?; let cover_bytes = files .get(&cover_field) .ok_or_else(|| import_invalid(format!("{} 缺少封面文件字段 {cover_field}", item.id)))? .clone(); let cover_content_type = uploaded_cover_content_type(&cover_bytes)?; let cover = validate_cover_bytes(cover_content_type, cover_bytes)?; let zip_sha256 = fingerprint(&zip_bytes); let cover_sha256 = fingerprint(&cover.bytes); Ok(PlannedImport { import: TemplateImport { id: item.id.clone(), title: item.title, summary: item.summary, tags: item.tags, runtime: item.runtime, engine: item.engine, engine_version: item.engine_version, template_version: item.template_version, entry: item.entry.trim().to_string(), zip_key: format!("templates/v1/{}/sha256/{zip_sha256}/template.zip", item.id), zip_sha256, zip_size_bytes: zip_bytes.len() as u64, cover_key: format!( "templates/v1/{}/sha256/{cover_sha256}/cover.{}", item.id, cover.extension ), cover_sha256, cover_width: cover.width, cover_height: cover.height, }, zip_bytes, cover_bytes: cover.bytes, cover_content_type, }) } /// 校验整批上传:任一模板不合法都在任何写入之前整批拒绝。 fn plan_import( manifest_bytes: &[u8], files: &BTreeMap>, ) -> Result<(String, Vec), AppError> { let manifest: AdminImportAgcTemplatesManifest = serde_json::from_slice(manifest_bytes) .map_err(|_| import_invalid("导入 manifest 不是有效 JSON 或包含未知字段"))?; if manifest.templates.is_empty() { return Err(import_invalid("导入批次不能为空")); } if manifest.templates.len() > MAX_TEMPLATE_IMPORT_BATCH { return Err(import_invalid(format!( "单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板" ))); } if manifest.expected_revision.len() != 64 || !manifest .expected_revision .bytes() .all(|byte| byte.is_ascii_hexdigit()) { return Err(import_invalid("模板库版本标识无效")); } let mut used_fields = BTreeSet::new(); let mut planned = Vec::with_capacity(manifest.templates.len()); for item in manifest.templates { let fields = [item.zip_field.trim(), item.cover_field.trim()]; for field in fields { if !field.starts_with("zip_") && !field.starts_with("cover_") { return Err(import_invalid(format!( "{} 的文件字段名必须以 zip_ 或 cover_ 开头", item.id ))); } if !used_fields.insert(field.to_string()) { return Err(import_invalid(format!("导入批次内文件字段名重复:{field}"))); } } planned.push(plan_import_item(item, files)?); } for name in files.keys() { if name != "manifest" && !used_fields.contains(name) { return Err(import_invalid(format!("请求包含未引用的文件字段:{name}"))); } } Ok((manifest.expected_revision, planned)) } async fn read_import_field( field: axum::extract::multipart::Field<'_>, limit: usize, name: &str, ) -> Result, AppError> { let mut field = field; let mut bytes = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|_| import_invalid(format!("读取 {name} 失败")))? { if bytes.len().saturating_add(chunk.len()) > limit { return Err(import_invalid(format!("{name} 超过允许的大小上限"))); } bytes.extend_from_slice(&chunk); } Ok(bytes) } pub async fn admin_import_agc_templates( State(state): State, Extension(context): Extension, Extension(_admin): Extension, mut multipart: Multipart, ) -> Result { let store = state.template_library_store().cloned().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("模板管理未配置可用的存储凭据,当前仅支持查看") })?; let mut manifest_bytes: Option> = None; let mut files: BTreeMap> = BTreeMap::new(); while let Some(field) = multipart .next_field() .await .map_err(|_| import_invalid("导入请求不是有效的 multipart 表单"))? { let name = field.name().unwrap_or_default().trim().to_string(); let limit = match name.as_str() { "manifest" => MAX_IMPORT_MANIFEST_BYTES, name if name.starts_with("zip_") => MAX_IMPORT_ZIP_BYTES, name if name.starts_with("cover_") => MAX_COVER_BYTES, _ => return Err(import_invalid(format!("导入请求包含未知字段:{name}"))), }; let bytes = read_import_field(field, limit, &name).await?; if name == "manifest" { manifest_bytes = Some(bytes); } else if files.insert(name.clone(), bytes).is_some() { return Err(import_invalid(format!("导入请求包含重复字段:{name}"))); } } let manifest_bytes = manifest_bytes.ok_or_else(|| import_invalid("导入请求缺少 manifest 字段"))?; let (expected_revision, planned) = plan_import(&manifest_bytes, &files)?; // 接受后的写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。 let result = tokio::spawn(async move { import_templates(store, expected_revision, planned).await }) .await .map_err(|_| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("模板导入结果需要核对,请刷新列表;若发布锁仍被占用请联系运维") })??; Ok(json_snapshot(&context, result)) } async fn import_templates( store: TemplateLibraryStore, expected_revision: String, planned: Vec, ) -> Result { let mut session = store .begin_publish(uuid::Uuid::new_v4().to_string()) .await .map_err(map_store_error)?; let outcome = async { let current = session.read_index().await.map_err(map_store_error)?; check_revision(¤t, &expected_revision)?; let index = decode_index(¤t)?; let updated_at = OffsetDateTime::now_utc() .format(&Rfc3339) .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; let mut prepared = prepare_template_import( index, planned.iter().map(|item| item.import.clone()).collect(), &updated_at, ) .map_err(map_domain_error)?; for item in &planned { let metadata = prepared .metadata .iter() .find(|(id, _)| id == &item.import.id) .map(|(_, value)| value.clone()) .ok_or_else(|| import_invalid("导入准备结果缺少元数据"))?; let metadata_bytes = document_bytes(&metadata)?; let metadata_key = format!( "templates/v1/{}/sha256/{}/template.json", item.import.id, fingerprint(&metadata_bytes) ); prepared .set_metadata_key(&item.import.id, &metadata_key) .map_err(map_domain_error)?; session .put_immutable( &item.import.zip_key, item.zip_bytes.clone(), "application/zip", ) .await .map_err(map_store_error)?; session .put_immutable( &item.import.cover_key, item.cover_bytes.clone(), item.cover_content_type, ) .await .map_err(map_store_error)?; session .put_immutable(&metadata_key, metadata_bytes, "application/json") .await .map_err(map_store_error)?; } let next_index_bytes = document_bytes(&prepared.index)?; session .commit_index(next_index_bytes.clone()) .await .map_err(map_store_error)?; let snapshot = snapshot(&next_index_bytes, true)?; let imported = planned .iter() .map(|item| AdminImportAgcTemplateResult { id: item.import.id.clone(), template_version: item.import.template_version.clone(), zip_size_bytes: item.import.zip_size_bytes, zip_sha256: item.import.zip_sha256.clone(), reused_objects: prepared.reused.contains(&item.import.id), }) .collect::>(); Ok::<_, AppError>((snapshot, imported)) } .await; let released = session.finish().await.map_err(map_store_error); match (outcome, released) { (Ok((snapshot, imported)), Ok(())) => Ok(AdminImportAgcTemplatesResponse { revision: snapshot.revision, writable: snapshot.writable, templates: snapshot.templates, imported, }), (Err(error), Ok(())) => Err(error), (_, Err(error)) => Err(error), } } fn map_domain_error(error: TemplateDomainError) -> AppError { match error { TemplateDomainError::InvalidEdit(message) => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) } TemplateDomainError::NotFound => { AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在") } _ => AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("模板库元数据无效,未进行修改"), } } fn map_store_error(error: TemplateStoreError) -> AppError { let (status, code) = match error { TemplateStoreError::NotFound => (StatusCode::NOT_FOUND, "TEMPLATE_LIBRARY_NOT_FOUND"), TemplateStoreError::Busy => (StatusCode::CONFLICT, "TEMPLATE_LIBRARY_BUSY"), TemplateStoreError::Uncertain => ( StatusCode::SERVICE_UNAVAILABLE, "TEMPLATE_LIBRARY_UNCERTAIN", ), _ => ( StatusCode::SERVICE_UNAVAILABLE, "TEMPLATE_LIBRARY_UNAVAILABLE", ), }; AppError::from_status(status) .with_code(code) .with_message(error.to_string()) } #[cfg(test)] mod tests { use super::*; fn png_input() -> AdminAgcTemplateCoverInput { let image = image::DynamicImage::new_rgb8(4, 3); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("PNG"); AdminAgcTemplateCoverInput { content_type: "image/png".to_string(), data_base64: STANDARD.encode(bytes.into_inner()), } } fn png_bytes() -> Vec { let image = image::DynamicImage::new_rgb8(4, 3); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("PNG"); bytes.into_inner() } fn archive(entries: &[(&str, &[u8], Option)]) -> Vec { use std::io::Write; let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, bytes, mode) in entries { let mut options = zip::write::SimpleFileOptions::default(); if let Some(mode) = mode { options = options.unix_permissions(*mode); } if name.ends_with('/') { writer.add_directory(*name, options).expect("directory"); continue; } writer.start_file(*name, options).expect("start file"); writer.write_all(bytes).expect("write file"); } writer.finish().expect("finish archive").into_inner() } fn import_item() -> AdminImportAgcTemplateItem { AdminImportAgcTemplateItem { id: "smoke-import".to_string(), title: " 冒烟模板 ".to_string(), summary: "简介".to_string(), tags: vec!["smoke".to_string()], runtime: "html".to_string(), engine: "none".to_string(), engine_version: String::new(), template_version: "0.1.0".to_string(), entry: "index.html".to_string(), zip_field: "zip_0".to_string(), cover_field: "cover_0".to_string(), } } fn import_files() -> BTreeMap> { let mut files = BTreeMap::new(); files.insert( "zip_0".to_string(), archive(&[("index.html", b"", None)]), ); files.insert("cover_0".to_string(), png_bytes()); files } fn import_manifest(item: AdminImportAgcTemplateItem) -> Vec { serde_json::to_vec(&AdminImportAgcTemplatesManifest { expected_revision: "a".repeat(64), templates: vec![item], }) .expect("manifest json") } #[test] fn template_import_archive_validation_fails_closed() { let ok = archive(&[ ("game/index.html", b"", None), ("game/app.js", b"console.log(1)", None), ]); assert!(validate_import_archive(&ok, "game/index.html").is_ok()); assert!( validate_import_archive(&ok, "game/missing.html") .expect_err("missing entry") .contains("缺少清单声明的 entry") ); let traversal = archive(&[("../escape.html", b"x", None)]); assert!(validate_import_archive(&traversal, "../escape.html").is_err()); let symlink = { let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); writer .add_symlink( "link.html", "index.html", zip::write::SimpleFileOptions::default(), ) .expect("symlink entry"); writer.finish().expect("finish archive").into_inner() }; assert!( validate_import_archive(&symlink, "link.html") .expect_err("symlink") .contains("符号链接") ); } #[test] fn template_import_archive_rejects_identity_and_build_directories() { let forbidden = [ (".agent/manifest.json", "模板包不允许包含 .agent 目录"), ("game/.agent/ledger.json", "模板包不允许包含 .agent 目录"), (".git/config", "模板包不允许包含 .git 目录"), (".svn/entries", "模板包不允许包含 .svn 目录"), ( "node_modules/three/package.json", "模板包不允许包含 node_modules 目录", ), ( "assets/node_modules/keep.txt", "模板包不允许包含 node_modules 目录", ), ("dist/game.js", "模板包根目录不允许包含 dist 目录"), ("build/index.html", "模板包根目录不允许包含 build 目录"), ("library/import.json", "模板包根目录不允许包含 library 目录"), ("temp/asset.json", "模板包根目录不允许包含 temp 目录"), ("local/settings.json", "模板包根目录不允许包含 local 目录"), ( ".vscode/settings.json", "模板包根目录不允许包含 .vscode 目录", ), (".idea/misc.xml", "模板包根目录不允许包含 .idea 目录"), ]; for (name, expected) in forbidden { let bytes = archive(&[ ("game/index.html", b"", None), (name, b"x", None), ]); assert_eq!( validate_import_archive(&bytes, "game/index.html").expect_err(name), expected, "条目 {name} 必须被拒绝" ); } // 同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容,`.gitignore` 也不是 `.git`。 let nested = archive(&[ ("game/index.html", b"", None), ("game/dist/app.js", b"x", None), ("game/.gitignore", b"node_modules\n", None), ]); assert!(validate_import_archive(&nested, "game/index.html").is_ok()); } #[test] fn template_import_plan_uses_content_addressed_keys_and_sniffed_cover() { let files = import_files(); let (revision, planned) = plan_import(&import_manifest(import_item()), &files).expect("plan import"); assert_eq!(revision, "a".repeat(64)); let item = &planned[0]; assert!( item.import .zip_key .starts_with("templates/v1/smoke-import/sha256/") ); assert!(item.import.zip_key.ends_with("/template.zip")); assert_eq!(item.import.zip_sha256, fingerprint(&files["zip_0"])); assert_eq!(item.import.zip_size_bytes, files["zip_0"].len() as u64); assert_eq!(item.cover_content_type, "image/png"); assert!(item.import.cover_key.ends_with("/cover.png")); assert_eq!((item.import.cover_width, item.import.cover_height), (4, 3)); } #[test] fn template_import_plan_rejects_unknown_and_unreferenced_fields() { let manifest = import_manifest(import_item()); let files = import_files(); assert!(plan_import(&manifest, &files).is_ok()); let mut unreferenced = files.clone(); unreferenced.insert("zip_1".to_string(), b"stray".to_vec()); let error = plan_import(&manifest, &unreferenced).expect_err("unreferenced file field"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!( error.message().contains("未引用的文件字段"), "{}", error.message() ); let mut missing_zip = files.clone(); missing_zip.remove("zip_0"); let error = plan_import(&manifest, &missing_zip).expect_err("missing zip field"); assert!( error.message().contains("缺少 ZIP 文件字段"), "{}", error.message() ); let mut wrong_prefix = import_item(); wrong_prefix.zip_field = "payload_0".to_string(); let error = plan_import(&import_manifest(wrong_prefix), &files) .expect_err("field prefix must be explicit"); assert!( error.message().contains("必须以 zip_ 或 cover_ 开头"), "{}", error.message() ); let unknown_field = serde_json::to_vec(&serde_json::json!({ "expectedRevision": "a".repeat(64), "templates": [], "extra": 1, })) .expect("json"); assert!(plan_import(&unknown_field, &files).is_err()); let broken_archive = { let mut files = files.clone(); files.insert("zip_0".to_string(), b"not a zip".to_vec()); files }; let error = plan_import(&manifest, &broken_archive).expect_err("invalid zip"); assert!( error.message().contains("不是有效 zip"), "{}", error.message() ); } #[test] fn template_cover_validates_actual_bytes_and_dimensions() { let cover = validate_cover(png_input()).expect("valid PNG"); assert_eq!((cover.width, cover.height, cover.extension), (4, 3, "png")); let mut mismatched = png_input(); mismatched.content_type = "image/jpeg".to_string(); assert_eq!( validate_cover(mismatched) .err() .expect("mismatch") .status_code(), StatusCode::BAD_REQUEST ); let svg = AdminAgcTemplateCoverInput { content_type: "image/svg+xml".to_string(), data_base64: STANDARD.encode(b""), }; assert!(validate_cover(svg).is_err()); let mut truncated = png_input(); truncated.data_base64 = STANDARD.encode([137, 80, 78, 71, 13, 10, 26, 10]); assert!(validate_cover(truncated).is_err()); } #[test] fn template_snapshot_lists_both_groups_and_uses_trusted_cover_urls() { let mut index: Value = serde_json::from_slice(include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json" ))).expect("published fixture"); let entries = index["templates"].as_array_mut().expect("templates"); let count = entries.len(); let hidden = entries.remove(0); let hidden_id = hidden["id"].as_str().expect("id").to_string(); index["inactiveTemplates"] = serde_json::json!([hidden]); let bytes = serde_json::to_vec(&index).expect("index bytes"); let result = snapshot(&bytes, false).expect("snapshot"); assert_eq!(result.revision, fingerprint(&bytes)); assert!(!result.writable); assert_eq!(result.templates.len(), count); assert!( !result .templates .iter() .find(|entry| entry.id == hidden_id) .expect("hidden") .enabled ); assert!( result .templates .iter() .all(|entry| entry.cover_url.starts_with(PUBLIC_BASE)) ); } #[test] fn template_cover_size_and_dimension_limits_reject_before_publication() { let oversized = AdminAgcTemplateCoverInput { content_type: "image/png".to_string(), data_base64: "A".repeat(MAX_COVER_BYTES.div_ceil(3) * 4 + 4), }; assert!(validate_cover(oversized).is_err()); let mut bytes = Cursor::new(Vec::new()); image::DynamicImage::new_rgb8(4097, 1) .write_to(&mut bytes, image::ImageFormat::Png) .expect("wide PNG"); assert!( validate_cover(AdminAgcTemplateCoverInput { content_type: "image/png".to_string(), data_base64: STANDARD.encode(bytes.into_inner()), }) .is_err() ); } #[test] fn template_edit_requires_the_exact_snapshot_revision() { let bytes = br#"{"templates":[]}"#; assert!(check_revision(bytes, &fingerprint(bytes)).is_ok()); assert_eq!( check_revision(bytes, &"0".repeat(64)) .expect_err("stale") .status_code(), StatusCode::CONFLICT ); assert_eq!( check_revision(bytes, "invalid") .expect_err("invalid") .status_code(), StatusCode::BAD_REQUEST ); } }