diff --git a/.github/workflows/pr-backend-validation.yml b/.github/workflows/pr-backend-validation.yml
new 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_metadata
diff --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.rs
new 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()
+  }
+}
