Browse / github.com/storytold/artcraft-services / patch
fix: extract OBJ from Fal ZIP packages (application/zip)
fix: extract OBJ from Fal ZIP packages (application/zip) ## Summary Fixes https://github.com/storytold/artcraft/issues/1327 (`Unsupported media file type: application/zip` for Fal OBJ results). No changes are made to `storytold/artcraft`. Fal can return an OBJ model wrapped in a ZIP package. Storing the ZIP as an OBJ would break every reader (they load raw OBJ text), and rejecting it keeps the alert firing. So the webhook now **unpacks the package and stores the raw OBJ**: - Add a Fal **mesh-only** resolver wired into the shared `upload_mesh_file`. - ZIP magic is checked before generic MIME/filename fallbacks, so a misleading `model/obj` hint can
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Alex72-py (imported from pull request #155, open)
- Base
e57ee3d63ac478a24149384bf1473ef97aa067f8- Size
- +493 −12 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e983feb5cc84cfa2599a9e53
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/pr-backend-validation.yml crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_mesh_file_metadata.rs
new file mode 100644@@ -0,0 +1,54 @@+name: PR Backend Validation++on:+ workflow_dispatch:+ push:+ branches:+ - fix/fal-obj-zip-mime+ pull_request:++permissions:+ contents: read++jobs:+ validate:+ runs-on: ubuntu-latest+ services:+ mysql:+ image: mysql:8.0+ env:+ MYSQL_ROOT_PASSWORD: root+ MYSQL_ROOT_HOST: "%"+ ports:+ - 3306:3306+ options: >-+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"+ --health-interval=10s+ --health-timeout=5s+ --health-retries=10+ timeout-minutes: 45+ env:+ SQLX_OFFLINE: "true"+ CARGO_TERM_COLOR: always+ steps:+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2+ with:+ persist-credentials: false+ - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # pinned commit+ with:+ toolchain: 1.93.0+ - name: Install native build dependencies+ run: |+ sudo apt-get update+ sudo apt-get install --no-install-recommends -y \+ build-essential cmake curl ffmpeg fontconfig git libclang-dev \+ libfontconfig1-dev libssl-dev musl-tools perl pkg-config+ - name: Check backend and test targets+ run: cargo check -p storyteller-web --tests+ - name: Run resolver tests without database fixtures+ run: cargo test -p storyteller-web --features skip_database_tests resolve_mesh_file_metadata++ - name: Run mesh uploader integration test with MySQL+ env:+ ARTCRAFT_TEST_DATABASE_URL: mysql://root:root@127.0.0.1:3306/artcraft_test+ run: cargo test -p storyteller-web --bin storyteller-web zip_obj_upload_stores_raw_obj_and_consistent_metadata@@ -9,3 +9,4 @@ mod process_model_mesh_payload; mod process_result_files_payload; mod process_video_payload; pub(in crate::http_server::endpoints::webhooks) mod resolve_file_metadata;+mod resolve_mesh_file_metadata;@@ -1,9 +1,10 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::webhooks::fal::process_success::attach_cover_image::{attach_cover_image, AttachCoverImageArgs}; use crate::http_server::endpoints::webhooks::fal::process_success::attach_prompt_imageref_cover::try_to_attach_prompt_imageref_cover;-use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata_with_file_name;+use crate::http_server::endpoints::webhooks::fal::process_success::resolve_mesh_file_metadata::resolve_mesh_file_metadata; use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;+use bytes::Bytes; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use enums::by_table::media_files::media_file_class::MediaFileClass; use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;@@ -147,9 +148,9 @@ pub(crate) async fn upload_mesh_file( })?; } - // Resolve mime type: magic bytes first, fal content_type next, then the- // file name's extension as a last resort.- let metadata = resolve_file_metadata_with_file_name(&file_bytes, maybe_content_type, maybe_file_name)+ // Resolve mesh metadata. A validated OBJ ZIP package is unpacked to its OBJ.+ // Other files retain the magic bytes / fal content_type / file name order.+ let mut metadata = resolve_mesh_file_metadata(&file_bytes, maybe_content_type, maybe_file_name) .ok_or_else(|| { warn!( "Could not determine file type for mesh (bytes: {}, fal content_type: {:?}, file_name: {:?})",@@ -162,18 +163,19 @@ pub(crate) async fn upload_mesh_file( file_bytes.len(), maybe_content_type)) })?; + // For a ZIP package, store the extracted raw OBJ (size, hash, and upload+ // below all use these bytes) rather than the downloaded archive.+ let file_bytes = match metadata.replacement_file_bytes.take() {+ Some(extracted) => Bytes::from(extracted),+ None => file_bytes,+ };+ let mime_type = metadata.mime_type.as_str(); info!("Mime type of mesh: {}, source: {:?}", mime_type, metadata.source); - let media_file_type = MediaFileType::try_from_mime_type(mime_type)- .ok_or_else(|| {- warn!("Unsupported media file type: {}", mime_type);- CommonWebError::server_error_with_message(- &format!("Unsupported media file type: {}", mime_type))- })?;-- let extension_with_period = metadata.file_extension.extension_with_period();+ let media_file_type = metadata.media_file_type;+ let extension_with_period = metadata.extension_with_period; let file_size_bytes = file_bytes.len(); let file_hash = sha256_hash_bytes(&file_bytes)@@ -268,3 +270,155 @@ async fn try_to_attach_preprocessed_image( server_state, }).await }+++// This test exercises the complete download -> ZIP extraction -> bucket write ->+// media_files row path. It uses the guarded test database and the existing+// in-process stub bucket from TestHarness; no real provider or storage is called.+#[cfg(all(test, not(feature = "skip_database_tests")))]+mod uploader_integration_tests {+ use std::io::{Cursor, Read, Write};+ use std::marker::PhantomData;+ use std::net::TcpListener;+ use std::sync::atomic::{AtomicU64, Ordering};+ use std::thread;+ use std::time::{SystemTime, UNIX_EPOCH};++ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;+ use enums::by_table::media_files::media_file_class::MediaFileClass;+ use enums::by_table::media_files::media_file_type::MediaFileType;+ use enums::common::visibility::Visibility;+ use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;+ use mysql_queries::queries::generic_inference::api_providers::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;+ use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::{+ insert_generic_inference_job_for_fal_queue, FalCategory, InsertGenericInferenceForFalArgs,+ };+ use mysql_queries::queries::media_files::get::get_media_file::get_media_file;+ use tokens::tokens::generic_inference_jobs::InferenceJobToken;+ use zip::write::FileOptions;+ use zip::ZipWriter;++ use super::{upload_mesh_file, UploadMeshFileArgs};+ use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness;++ const OBJ_BYTES: &[u8] = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";++ #[tokio::test]+ async fn zip_obj_upload_stores_raw_obj_and_consistent_metadata() {+ let harness = TestHarness::create().await;++ // Insert a real Fal job so the uploaded media row uses a valid source job.+ let generated_job_token = InferenceJobToken::generate();+ let external_job_id = format!("mesh-upload-e2e-{}", generated_job_token.as_str());+ let now = SystemTime::now()+ .duration_since(UNIX_EPOCH)+ .expect("system clock")+ .as_nanos() as u64;+ static NEXT_ID: AtomicU64 = AtomicU64::new(0);+ let idempotency_token = format!("{:016x}{:016x}", now, NEXT_ID.fetch_add(1, Ordering::Relaxed));++ let mut connection = harness.pool.acquire().await.expect("acquire test database connection");+ insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {+ uuid_idempotency_token: &idempotency_token,+ maybe_external_third_party_id: &external_job_id,+ fal_category: FalCategory::ObjectGeneration,+ maybe_model_type: None,+ maybe_inference_args: None,+ maybe_prompt_token: None,+ maybe_creator_user_token: None,+ maybe_avt_token: None,+ creator_ip_address: "127.0.0.1",+ creator_set_visibility: Visibility::Public,+ maybe_platform_type: None,+ mysql_executor: &mut *connection,+ phantom: PhantomData,+ })+ .await+ .expect("insert test Fal job");+ drop(connection);++ let job = get_inference_job_by_fal_id(&external_job_id, &harness.pool)+ .await+ .expect("read test Fal job")+ .expect("test Fal job exists");++ let zip_bytes = make_obj_package();+ let source_url = serve_bytes_once(zip_bytes.clone());++ // Deliberately supply misleading non-ZIP hints: ZIP magic must win.+ let media_token = upload_mesh_file(UploadMeshFileArgs {+ mesh_url: &source_url,+ maybe_content_type: Some("model/obj"),+ maybe_file_name: Some("model.obj"),+ maybe_batch_token: None,+ job: &job,+ server_state: &harness.server_state,+ })+ .await+ .unwrap_or_else(|err| panic!("mesh upload failed: {:?}", err));++ let media = get_media_file(&media_token, true, &harness.pool)+ .await+ .expect("query uploaded media row")+ .expect("uploaded media row exists");++ assert_eq!(media.media_type, MediaFileType::Obj);+ assert_eq!(media.media_class, MediaFileClass::Mesh);+ assert_eq!(media.maybe_mime_type.as_deref(), Some("model/obj"));+ assert_eq!(media.maybe_public_bucket_extension.as_deref(), Some(".obj"));++ let (file_size_bytes, checksum_sha2): (i32, String) =+ sqlx::query_as("SELECT file_size_bytes, checksum_sha2 FROM media_files WHERE token = ?")+ .bind(media_token.as_str())+ .fetch_one(&harness.pool)+ .await+ .expect("read stored size and checksum");++ assert_eq!(file_size_bytes, OBJ_BYTES.len() as i32);+ assert_eq!(checksum_sha2, sha256_hash_bytes(OBJ_BYTES).expect("hash OBJ bytes"));++ // Read the uploaded object back through the actual LegacyBucketClient.+ let object_path = MediaFileBucketPath::from_object_hash(+ &media.public_bucket_directory_hash,+ media.maybe_public_bucket_prefix.as_deref(),+ media.maybe_public_bucket_extension.as_deref(),+ );+ let stored_bytes = harness+ .server_state+ .public_bucket_client+ .download_file(object_path.get_full_object_path_str())+ .await+ .expect("download uploaded mesh from the test bucket");++ assert_eq!(stored_bytes, OBJ_BYTES, "bucket must contain raw OBJ, not ZIP bytes");+ assert_ne!(stored_bytes, zip_bytes);+ }++ fn make_obj_package() -> Vec<u8> {+ let mut writer = ZipWriter::new(Cursor::new(Vec::new()));+ writer+ .start_file("model.obj", FileOptions::default())+ .expect("start OBJ entry");+ writer.write_all(OBJ_BYTES).expect("write OBJ entry");+ writer.finish().expect("finish ZIP package").into_inner()+ }++ fn serve_bytes_once(body: Vec<u8>) -> String {+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test HTTP server");+ let address = listener.local_addr().expect("local test HTTP server address");++ thread::spawn(move || {+ let (mut stream, _) = listener.accept().expect("accept mesh download");+ let mut request = [0_u8; 2048];+ let _ = stream.read(&mut request);+ let headers = format!(+ "HTTP/1.1 200 OK\r\nContent-Type: application/zip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",+ body.len(),+ );+ stream.write_all(headers.as_bytes()).expect("write HTTP headers");+ stream.write_all(&body).expect("write ZIP response body");+ });++ format!("http://{address}/model.zip")+ }+}new file mode 100644@@ -0,0 +1,272 @@+//! Mesh-only classification for Fal results. ZIP is a container, not a global+//! alias for OBJ. Fal can return an OBJ model wrapped in a ZIP package. The+//! package is validated and the single OBJ inside is extracted, so what gets+//! stored is a genuine raw OBJ that every existing mesh reader already handles.++use std::io::{Cursor, Read};+use std::path::Path;++use enums::by_table::media_files::media_file_type::MediaFileType;+use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;+use zip::ZipArchive;++use super::resolve_file_metadata::{resolve_file_metadata_with_file_name, MetadataSource};++const MAX_OBJ_PACKAGE_ENTRIES: usize = 1024;+// Limit on the sizes *declared* in the ZIP entry headers, i.e. what the package+// claims. Declared sizes are untrusted, so extraction enforces its own limit.+const MAX_OBJ_PACKAGE_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;+// Hard limit on the bytes actually read when extracting the OBJ.+const MAX_EXTRACTED_OBJ_BYTES: u64 = 256 * 1024 * 1024;++pub(super) struct ResolvedMeshFileMetadata {+ pub mime_type: String,+ pub extension_with_period: &'static str,+ pub media_file_type: MediaFileType,+ pub source: MetadataSource,+ /// Set when the downloaded bytes were a ZIP package: the OBJ extracted from+ /// it. The caller must hash and store these bytes instead of the download.+ pub replacement_file_bytes: Option<Vec<u8>>,+}++pub(super) fn resolve_mesh_file_metadata(file_bytes: &[u8], fal_content_type: Option<&str>, maybe_file_name: Option<&str>) -> Option<ResolvedMeshFileMetadata> {+ // Inspect ZIP magic before the generic resolver: ZIP has no globally+ // supported FileExtension, so that resolver may fall back to a misleading+ // webhook MIME type or filename (e.g. model/obj or model.obj).+ let is_zip = MimetypeInfo::get_for_bytes(file_bytes).map(|info| info.mime_type() == "application/zip").unwrap_or(false);++ if is_zip {+ // Unpack the single OBJ and classify it like any other OBJ. The extracted+ // bytes must resolve to an OBJ (never to a nested archive or other format).+ let obj_bytes = extract_obj_from_package(file_bytes)?;+ // OBJ is plain text with no magic bytes. Anything recognizable as another+ // format (a nested archive, an image, ...) is not an OBJ, whatever its name.+ if MimetypeInfo::get_for_bytes(&obj_bytes).is_some() {+ return None;+ }+ let mut resolved = resolve_non_zip(&obj_bytes, Some("model/obj"), Some("model.obj"))?;+ if resolved.media_file_type != MediaFileType::Obj {+ return None;+ }+ resolved.replacement_file_bytes = Some(obj_bytes);+ return Some(resolved);+ }++ resolve_non_zip(file_bytes, fal_content_type, maybe_file_name)+}++// Unrelated formats keep exactly the existing resolution and mapping.+fn resolve_non_zip(file_bytes: &[u8], fal_content_type: Option<&str>, maybe_file_name: Option<&str>) -> Option<ResolvedMeshFileMetadata> {+ let metadata = resolve_file_metadata_with_file_name(file_bytes, fal_content_type, maybe_file_name)?;+ let media_file_type = MediaFileType::try_from_mime_type(&metadata.mime_type)?;+ Some(ResolvedMeshFileMetadata { mime_type: metadata.mime_type, extension_with_period: metadata.file_extension.extension_with_period(), media_file_type, source: metadata.source, replacement_file_bytes: None })+}++/// Validate a package and extract its OBJ.+/// Supports a single nonempty OBJ plus optional MTLs and texture images, not+/// arbitrary archives, multi-model bundles, or other mesh/project formats.+/// Only the OBJ entry is read; other entries are checked but never unpacked.+fn extract_obj_from_package(file_bytes: &[u8]) -> Option<Vec<u8>> {+ extract_obj_from_package_with_limit(file_bytes, MAX_EXTRACTED_OBJ_BYTES)+}++fn extract_obj_from_package_with_limit(file_bytes: &[u8], max_extracted_bytes: u64) -> Option<Vec<u8>> {+ let mut archive = ZipArchive::new(Cursor::new(file_bytes)).ok()?;+ if archive.len() > MAX_OBJ_PACKAGE_ENTRIES {+ return None;+ }++ let mut obj_index = None;+ let mut total_size = 0u64;+ for index in 0..archive.len() {+ let file = archive.by_index(index).ok()?;+ if file.enclosed_name().is_none() || file.name().contains('\\') {+ return None;+ }+ // Only directories and regular files, never symlinks or special files.+ if let Some(mode) = file.unix_mode() {+ let kind = mode & 0o170000;+ if kind != 0 && kind != 0o100000 && kind != 0o040000 {+ return None;+ }+ }+ if file.is_dir() {+ continue;+ }+ // `size()` is the uncompressed size declared in the header, not a measured one.+ total_size = total_size.checked_add(file.size())?;+ if total_size > MAX_OBJ_PACKAGE_UNCOMPRESSED_BYTES {+ return None;+ }+ let extension = Path::new(file.name()).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_ascii_lowercase();+ match extension.as_str() {+ "obj" if file.size() > 0 && obj_index.is_none() => obj_index = Some(index),+ "mtl" | "png" | "jpg" | "jpeg" | "webp" => {},+ _ => return None,+ }+ }++ // The declared size is untrusted, so bound the bytes actually read.+ let mut obj_file = archive.by_index(obj_index?).ok()?;+ let mut obj_bytes = Vec::new();+ Read::take(&mut obj_file, max_extracted_bytes + 1).read_to_end(&mut obj_bytes).ok()?;+ if obj_bytes.is_empty() || obj_bytes.len() as u64 > max_extracted_bytes {+ return None;+ }+ Some(obj_bytes)+}++#[cfg(test)]+mod tests {+ use std::io::Write;++ use zip::write::FileOptions;+ use zip::ZipWriter;++ use super::*;++ const OBJ: &[u8] = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";++ mod obj_packages {+ use super::*;++ #[test]+ fn obj_package_is_extracted_to_raw_obj() {+ let bytes = package(&[("model/model.OBJ", OBJ), ("model/material.mtl", b"newmtl material"), ("model/texture.png", b"texture")]);+ let metadata = resolve_mesh_file_metadata(&bytes, Some("application/zip"), Some("model.zip")).unwrap();+ assert_eq!(metadata.media_file_type, MediaFileType::Obj);+ assert_eq!(metadata.mime_type, "model/obj");+ assert_eq!(metadata.extension_with_period, ".obj");+ assert_eq!(metadata.replacement_file_bytes.as_deref(), Some(OBJ));+ }++ #[test]+ fn obj_package_extracted_without_or_with_misleading_hints() {+ let bytes = package(&[("model.obj", OBJ)]);+ for (content_type, file_name) in [(None, None), (Some("model/obj"), Some("model.obj")), (Some("application/octet-stream"), None)] {+ let metadata = resolve_mesh_file_metadata(&bytes, content_type, file_name).unwrap();+ assert_eq!(metadata.media_file_type, MediaFileType::Obj);+ assert_eq!(metadata.replacement_file_bytes.as_deref(), Some(OBJ));+ }+ }++ #[test]+ fn extraction_is_bounded_by_bytes_actually_read() {+ let bytes = package(&[("model.obj", OBJ)]);+ assert!(extract_obj_from_package_with_limit(&bytes, OBJ.len() as u64).is_some());+ assert!(extract_obj_from_package_with_limit(&bytes, OBJ.len() as u64 - 1).is_none());+ }++ #[test]+ fn nested_archive_inside_obj_entry_rejected() {+ let inner = package(&[("model.obj", OBJ)]);+ let bytes = package(&[("model.obj", &inner)]);+ assert!(resolve_mesh_file_metadata(&bytes, Some("application/zip"), None).is_none());+ }+ }++ mod existing_formats {+ use super::*;++ #[test]+ fn supported_obj_responses_are_unchanged() {+ for (content_type, file_name) in [(Some("model/obj"), None), (None, Some("model.OBJ")), (Some("application/octet-stream"), Some("model.obj"))] {+ let metadata = resolve_mesh_file_metadata(OBJ, content_type, file_name).unwrap();+ assert_eq!(metadata.media_file_type, MediaFileType::Obj);+ assert_eq!(metadata.mime_type, "model/obj");+ assert_eq!(metadata.extension_with_period, ".obj");+ }+ }++ #[test]+ fn existing_fbx_and_glb_responses_are_unchanged() {+ for (content_type, file_name, expected) in [("model/fbx", "model.fbx", MediaFileType::Fbx), ("model/gltf-binary", "model.glb", MediaFileType::Glb)] {+ assert_eq!(resolve_mesh_file_metadata(b"unknown bytes", Some(content_type), Some(file_name)).unwrap().media_file_type, expected);+ }+ }++ #[test]+ fn non_archive_magic_still_takes_priority() {+ let png = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82];+ let metadata = resolve_mesh_file_metadata(&png, Some("application/zip"), Some("model.obj")).unwrap();+ assert_eq!(metadata.media_file_type, MediaFileType::Png);+ assert_eq!(metadata.source, MetadataSource::MagicBytes);+ }+ }++ mod unsupported_files {+ use super::*;++ #[test]+ fn zip_is_not_a_global_obj_alias() {+ assert_eq!(MediaFileType::try_from_mime_type("application/zip"), None);+ }++ #[test]+ fn unrelated_archives_rejected_even_with_obj_hints() {+ for entries in [vec![("document.txt", b"document".as_slice())], vec![("model.glb", b"glTF".as_slice())], vec![("material.mtl", b"newmtl material".as_slice())]] {+ let bytes = package(&entries);+ assert!(resolve_mesh_file_metadata(&bytes, Some("application/zip"), Some("model.zip")).is_none());+ assert!(resolve_mesh_file_metadata(&bytes, Some("model/obj"), Some("model.obj")).is_none());+ }+ }++ #[test]+ fn unsafe_or_unsupported_package_contents_rejected() {+ for extra in ["../texture.png", "/texture.png", "textures\\texture.png", "program.exe", "second.obj"] {+ let bytes = package(&[("model.obj", OBJ), (extra, OBJ)]);+ assert!(resolve_mesh_file_metadata(&bytes, Some("application/zip"), None).is_none(), "accepted {}", extra);+ }+ }++ #[test]+ fn empty_obj_and_empty_archive_rejected() {+ assert!(resolve_mesh_file_metadata(&package(&[("model.obj", b"")]), Some("application/zip"), None).is_none());+ assert!(resolve_mesh_file_metadata(&package(&[]), Some("application/zip"), None).is_none());+ }++ #[test]+ fn corrupt_zip_rejected_even_with_obj_hints() {+ let mut bytes = package(&[("model.obj", OBJ)]);+ bytes.truncate(40);+ assert!(resolve_mesh_file_metadata(&bytes, Some("model/obj"), Some("model.obj")).is_none());+ }++ #[test]+ fn generic_resolver_does_not_accept_obj_packages() {+ let bytes = package(&[("model.obj", OBJ)]);+ assert!(resolve_file_metadata_with_file_name(&bytes, Some("application/zip"), Some("model.zip")).is_none());+ }++ #[test]+ fn too_many_package_entries_rejected() {+ let mut entries = vec![("material.mtl", b"newmtl material".as_slice()); MAX_OBJ_PACKAGE_ENTRIES];+ entries.push(("model.obj", OBJ));+ assert!(resolve_mesh_file_metadata(&package(&entries), Some("application/zip"), None).is_none());+ }++ #[test]+ fn symlink_in_package_rejected() {+ let mut writer = ZipWriter::new(Cursor::new(Vec::new()));+ writer.start_file("model.obj", FileOptions::default()).unwrap();+ writer.write_all(OBJ).unwrap();+ writer.add_symlink("texture.png", "outside.png", FileOptions::default()).unwrap();+ let bytes = writer.finish().unwrap().into_inner();+ assert!(resolve_mesh_file_metadata(&bytes, Some("application/zip"), None).is_none());+ }++ #[test]+ fn unknown_media_remains_unsupported() {+ assert!(resolve_mesh_file_metadata(b"unknown bytes", Some("application/x-unsupported"), Some("file.xyz")).is_none());+ }+ }++ fn package(entries: &[(&str, &[u8])]) -> Vec<u8> {+ let mut writer = ZipWriter::new(Cursor::new(Vec::new()));+ for (name, contents) in entries {+ writer.start_file(*name, FileOptions::default()).unwrap();+ writer.write_all(contents).unwrap();+ }+ writer.finish().unwrap().into_inner()+ }+}