collective

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

diff --git a/.github/workflows/pr-backend-validation.yml b/.github/workflows/pr-backend-validation.ymlnew file mode 100644--- /dev/null+++ b/.github/workflows/pr-backend-validation.yml@@ -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_metadatadiff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs@@ -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;diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs@@ -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")+  }+}diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_mesh_file_metadata.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_mesh_file_metadata.rsnew file mode 100644--- /dev/null+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_mesh_file_metadata.rs@@ -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()+  }+}