Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 41 additions & 25 deletions src/c2pa/c2pa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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):
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
154 changes: 140 additions & 14 deletions tests/test_unit_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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())
Expand Down
Loading