diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index fa589a6a..9ca6f957 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1196,16 +1196,20 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: def _get_mime_type_from_path(path: Union[str, Path]) -> str: - """Attempt to guess the MIME type from a file path (with extension). + """Attempt to guess the MIME type from a file path's extension. + When the extension is missing or unrecognized, this returns an empty + string so the caller hands it to the lib for auto-detection. + A recognized-but-wrong extension still returns a mimetype: + the native layer will attempt to correct it from the bytes when + reading (the real type still needs to be supported by the lib). Args: path: File path as string or Path object Returns: - MIME type string - - Raises: - C2paError.NotSupported: If MIME type cannot be determined + MIME type string, or an empty string + (when it cannot be determined from the extension). + An empty string here means the native lib should attempt auto-detect. """ path_obj = Path(path) file_extension = path_obj.suffix.lower() if path_obj.suffix else "" @@ -1215,11 +1219,9 @@ def _get_mime_type_from_path(path: Union[str, Path]) -> str: # so we bypass it and set the correct type return "image/dng" else: - mime_type = mimetypes.guess_type(str(path))[0] - if not mime_type: - raise C2paError.NotSupported( - f"Could not determine MIME type for file: {path}") - return mime_type + # Fall back to an empty string for extensionless or unknown files. + # Empty string flags this as a guess-type attempt. + return mimetypes.guess_type(str(path))[0] or "" class ContextProvider(ABC): @@ -1971,20 +1973,31 @@ def _get_supported_mime_types(ffi_func, cache): def _validate_and_encode_format( format_str: str, supported_types: list[str], class_name: str ) -> bytes: - """Validate a MIME type / format string and encode it to UTF-8 bytes. + """Validate a MIME type/format string and encode it to UTF-8 bytes. + + A blank format (e.g. empty) is treated as a request for format auto-detect: + it skips the supported-types check and encodes to empty bytes, + letting the native library guess the format. + A non-empty format is still validated against the supported list. Args: - format_str: The MIME type or format string to validate + format_str: The MIME type or format string to validate. Pass an empty + or whitespace-only string to request auto-detection from the + asset's bytes. supported_types: List of supported MIME types class_name: Name of the calling class (for error messages) Returns: - UTF-8 encoded format bytes + UTF-8 encoded format bytes (empty bytes for auto-detection) Raises: - C2paError.NotSupported: If the format is not supported - C2paError.Encoding: If the string contains invalid UTF-8 characters + C2paError.NotSupported: If format non-empty and + non-empty format unsupported. + C2paError.Encoding: If the non-empty string contains + invalid UTF-8 characters """ + if not format_str.strip(): + return b"" if format_str.lower() not in supported_types: raise C2paError.NotSupported( f"{class_name} does not support {format_str}") @@ -2136,14 +2149,22 @@ def __init__( ): """Create a new Reader. + The format is optional: pass an empty string (or read an extensionless + file by path) to let the native library detect the format. + A recognized but wrong format/extension is also corrected from the bytes + when reading (so reading doesn't fail on wrong file extension). + If the bytes are not a recognized asset, a C2paError is raised. + Args: - format_or_path: The format or path to read from + format_or_path: The format (MIME type) or path to read from. + An empty string is an auto-detection request. stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes context: Optional context implementing ContextProvider with settings Raises: - C2paError: If there was an error creating the reader + C2paError: If there was an error creating the reader, including + when the format cannot be detected from an unrecognized asset C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ @@ -2175,14 +2196,11 @@ def __init__( supported = Reader.get_supported_mime_types() if stream is None: - # Create a stream from the file path in format_or_path + # Create a stream from the file path in format_or_path. + # Empty format = native lib will try to guess format. path = str(format_or_path) mime_type = _get_mime_type_from_path(path) - if not mime_type: - raise C2paError.NotSupported( - f"Could not determine MIME type for file: {path}") - format_bytes = _validate_and_encode_format( mime_type, supported, "Reader") self._init_from_file(path, format_bytes) @@ -2272,11 +2290,9 @@ def _init_from_context(self, context, format_or_path, supported = Reader.get_supported_mime_types() if stream is None: + # Empty format = native lib will try to guess format. path = str(format_or_path) mime_type = _get_mime_type_from_path(path) - if not mime_type: - raise C2paError.NotSupported( - f"Could not determine MIME type for file: {path}") format_bytes = _validate_and_encode_format( mime_type, supported, "Reader") self._backing_file = open(path, 'rb') diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index e0580ccb..d4f212d1 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -299,23 +299,32 @@ def test_try_create_from_path(self): def test_stream_read_string_stream_mimetype_not_supported(self): with self.assertRaises(Error.NotSupported): - # xyz is actually an extension that is recognized - # as mimetype chemical/x-xyz - Reader(os.path.join(FIXTURES_DIR, "C.xyz")) + # txt maps to text/plain in Python's built-in mimetypes + # default map, and text/plain isn't a supported mimetype now + Reader(os.path.join(FIXTURES_DIR, "C.txt")) def test_try_create_raises_mimetype_not_supported(self): with self.assertRaises(Error.NotSupported): - # xyz is actually an extension that is recognized - # as mimetype chemical/x-xyz, but we don't support it - Reader.try_create(os.path.join(FIXTURES_DIR, "C.xyz")) - - def test_stream_read_string_stream_mimetype_not_recognized(self): - with self.assertRaises(Error.NotSupported): - Reader(os.path.join(FIXTURES_DIR, "C.test")) - - def test_try_create_raises_mimetype_not_recognized(self): - with self.assertRaises(Error.NotSupported): - Reader.try_create(os.path.join(FIXTURES_DIR, "C.test")) + # txt maps to text/plain in Python's built-in mimetypes + # default map, and text/plain isn't a supported mimetype now + Reader.try_create(os.path.join(FIXTURES_DIR, "C.txt")) + + def test_unrecognized_extension_defers_to_detection(self): + with tempfile.TemporaryDirectory() as tmp: + asset = os.path.join(tmp, "C.test") + shutil.copyfile(self.testPath, asset) + with Reader(asset) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_try_create_unrecognized_extension_defers_to_detection(self): + with tempfile.TemporaryDirectory() as tmp: + asset = os.path.join(tmp, "C.test") + shutil.copyfile(self.testPath, asset) + reader = Reader.try_create(asset) + self.assertIsNotNone(reader) + if reader is not None: + with reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) def test_stream_read_string_stream(self): with Reader("image/jpeg", self.testPath) as reader: @@ -326,6 +335,123 @@ def test_reader_detects_unsupported_mimetype_on_file(self): with self.assertRaises(Error.NotSupported): Reader("mimetype/does-not-exist", self.testPath) + def test_read_extensionless_file_path(self): + # Extensionless file triggers type auto-detection. + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "C") + shutil.copyfile(self.testPath, no_ext) + with Reader(no_ext) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_extensionless_stream_empty_format(self): + # An empty format string on a stream triggers auto-detection. + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_wrong_extension_corrected(self): + # A JPEG saved with a .png extension is corrected from the bytes. + # (Read is possible and does not fail due to wrong type). + with tempfile.TemporaryDirectory() as tmp: + wrong = os.path.join(tmp, "C.png") + shutil.copyfile(self.testPath, wrong) + with Reader(wrong) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_wrong_mime_corrected_on_stream(self): + # A wrong MIME hint on a stream is corrected from the bytes. + with open(self.testPath, "rb") as file: + with Reader("image/png", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_explicit_empty_format_forces_detection(self): + # Passing "" works even when the correct format is supported. + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + manifest_store = json.loads(reader.json()) + self.assertIn("active_manifest", manifest_store) + + def test_autodetect_multiformat(self): + fixtures = [ + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "CA.jpg"), + os.path.join(FIXTURES_DIR, "video1.mp4"), + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "pdf-file.pdf"), + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "sample1_signed.wav"), + ] + for path in fixtures: + with self.subTest(path=path): + with open(path, "rb") as file: + with Reader("", file) as reader: + self.assertIn("active_manifest", json.loads(reader.json())) + + def test_dng_extension_still_special_cased(self): + dng_path = os.path.join(FIXTURES_DIR, "C.dng") + with Reader(dng_path) as reader: + self.assertIn("active_manifest", json.loads(reader.json())) + + def test_garbage_stream_empty_format_raises(self): + # Unrecognized bytes with auto-detection raise rather than crash. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"not an asset at all")) + + def test_empty_stream_raises(self): + # A zero-byte stream cannot be detected and raises. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"")) + + def test_truncated_corrupt_jpeg_raises(self): + with open(self.testPath, "rb") as file: + data = bytearray(file.read()) + # Corrupt the body while keeping the leading JPEG magic intact. + # This will still raise errors. + for i in range(64, min(len(data), 4096)): + data[i] ^= 0xFF + with self.assertRaises(Error): + Reader("", io.BytesIO(bytes(data))) + + def test_garbage_extensionless_file_raises(self): + # Random bytes in an extensionless file raise. + with tempfile.TemporaryDirectory() as tmp: + garbage = os.path.join(tmp, "garbage") + with open(garbage, "wb") as f: + f.write(b"this is not a media asset") + with self.assertRaises(Error): + Reader(garbage) + + def test_try_create_garbage_stream_raises(self): + with self.assertRaises(Error) as context: + Reader.try_create("", io.BytesIO(b"not an asset at all")) + self.assertNotIn("ManifestNotFound", str(context.exception)) + + def test_try_create_extensionless_no_manifest_returns_none(self): + unsigned = os.path.join( + FIXTURES_DIR, "files-for-signing-tests", "earth_apollo17.jpg") + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "unsigned") + shutil.copyfile(unsigned, no_ext) + self.assertIsNone(Reader.try_create(no_ext)) + + def test_sub_magic_length_stream_raises(self): + # Fewer bytes than a full magic signature cannot be detected. + # This will raise too. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"\xff\xd8\xff")) + + def test_preseeked_stream_is_rewound(self): + with open(self.testPath, "rb") as file: + data = file.read() + stream = io.BytesIO(data) + stream.seek(len(data) // 2) + with Reader("", stream) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_dng_extension_with_jpeg_bytes_corrected(self): + with tempfile.TemporaryDirectory() as tmp: + fake_dng = os.path.join(tmp, "fake.dng") + shutil.copyfile(self.testPath, fake_dng) + with Reader(fake_dng) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + def test_stream_read_filepath_as_stream_and_parse(self): with Reader("image/jpeg", self.testPath) as reader: manifest_store = json.loads(reader.json())