c2pa-python 0.32.14__tar.gz → 0.35.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {c2pa_python-0.32.14/src/c2pa_python.egg-info → c2pa_python-0.35.0}/PKG-INFO +1 -1
  2. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/pyproject.toml +1 -1
  3. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/scripts/download_artifacts.py +1 -1
  4. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/setup.py +2 -1
  5. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa/__init__.py +0 -2
  6. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa/c2pa.py +67 -283
  7. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa/lib.py +30 -2
  8. {c2pa_python-0.32.14 → c2pa_python-0.35.0/src/c2pa_python.egg-info}/PKG-INFO +1 -1
  9. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/tests/test_unit_tests.py +1 -487
  10. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/tests/test_unit_tests_threaded.py +161 -1
  11. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/LICENSE-APACHE +0 -0
  12. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/LICENSE-MIT +0 -0
  13. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/MANIFEST.in +0 -0
  14. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/README.md +0 -0
  15. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/requirements.txt +0 -0
  16. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/setup.cfg +0 -0
  17. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa/build.py +0 -0
  18. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  19. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  20. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  21. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/requires.txt +0 -0
  22. {c2pa_python-0.32.14 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.32.14
3
+ Version: 0.35.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.32.14"
7
+ version = "0.35.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library"
10
10
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -91,7 +91,7 @@ def get_platform_identifier(target_arch=None):
91
91
  else:
92
92
  return "universal-apple-darwin"
93
93
  elif system == "windows":
94
- if target_arch == "arm64":
94
+ if target_arch == "arm64" or machine in ["arm64", "aarch64"]:
95
95
  return "aarch64-pc-windows-msvc"
96
96
  else:
97
97
  return "x86_64-pc-windows-msvc"
@@ -41,6 +41,7 @@ PLATFORM_FOLDERS = {
41
41
  'aarch64-apple-darwin': 'dylib',
42
42
  'x86_64-apple-darwin': 'dylib',
43
43
  'x86_64-pc-windows-msvc': 'dll',
44
+ 'aarch64-pc-windows-msvc': 'dll',
44
45
  'x86_64-unknown-linux-gnu': 'so',
45
46
  'aarch64-unknown-linux-gnu': 'so',
46
47
  }
@@ -78,7 +79,7 @@ def get_platform_identifier(target_arch=None) -> str:
78
79
  else:
79
80
  return "universal-apple-darwin"
80
81
  elif system == "windows":
81
- if target_arch == "arm64":
82
+ if target_arch == "arm64" or platform.machine().lower() in ["arm64", "aarch64"]:
82
83
  return "aarch64-pc-windows-msvc"
83
84
  else:
84
85
  return "x86_64-pc-windows-msvc"
@@ -32,7 +32,6 @@ from .c2pa import (
32
32
  ContextBuilder,
33
33
  ContextProvider,
34
34
  sdk_version,
35
- read_ingredient_file,
36
35
  load_settings
37
36
  ) # NOQA
38
37
 
@@ -52,6 +51,5 @@ __all__ = [
52
51
  'ContextBuilder',
53
52
  'ContextProvider',
54
53
  'sdk_version',
55
- 'read_ingredient_file',
56
54
  'load_settings'
57
55
  ]
@@ -22,7 +22,7 @@ from abc import ABC, abstractmethod
22
22
  from pathlib import Path
23
23
  from typing import Optional, Union, Callable, Any, overload
24
24
  import io
25
- from .lib import dynamically_load_library
25
+ from .lib import dynamically_load_library, record_owner_pid, is_foreign_process
26
26
  import mimetypes
27
27
  from itertools import count
28
28
 
@@ -38,8 +38,6 @@ _REQUIRED_FUNCTIONS = [
38
38
  'c2pa_error',
39
39
  # Legacy APIs, deprecated
40
40
  'c2pa_load_settings',
41
- 'c2pa_read_file',
42
- 'c2pa_read_ingredient_file',
43
41
  # Stream
44
42
  'c2pa_create_stream',
45
43
  'c2pa_release_stream',
@@ -240,11 +238,17 @@ class ManagedResource:
240
238
  self._lifecycle_state = LifecycleState.UNINITIALIZED
241
239
  self._handle = None
242
240
  _clear_error_state()
241
+ record_owner_pid(self)
243
242
 
244
243
  @staticmethod
245
244
  def _free_native_ptr(ptr):
246
- """Free a native pointer by casting it to c_void_p and calling c2pa_free."""
247
- _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p))
245
+ """Free a native pointer by passing it to c2pa_free.
246
+
247
+ c2pa_free's argtype is c_void_p, so ctypes converts any pointer
248
+ instance directly. (ctypes.cast(ptr, c_void_p) would do the same
249
+ conversion but leaves a reference cycle behind on every call.)
250
+ """
251
+ _lib.c2pa_free(ptr)
248
252
 
249
253
  def _ensure_valid_state(self):
250
254
  """Raise if the resource is closed or uninitialized."""
@@ -277,6 +281,8 @@ class ManagedResource:
277
281
  def _cleanup_resources(self):
278
282
  """Release native resources idempotently."""
279
283
  try:
284
+ if is_foreign_process(self):
285
+ return
280
286
  if (
281
287
  hasattr(self, '_lifecycle_state')
282
288
  and self._lifecycle_state != LifecycleState.CLOSED
@@ -645,12 +651,6 @@ _setup_function(_lib.c2pa_signer_create,
645
651
  _setup_function(_lib.c2pa_signer_from_info,
646
652
  [ctypes.POINTER(C2paSignerInfo)],
647
653
  ctypes.POINTER(C2paSigner))
648
- _setup_function(
649
- _lib.c2pa_read_file, [
650
- ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p)
651
- _setup_function(
652
- _lib.c2pa_read_ingredient_file, [
653
- ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p)
654
654
 
655
655
  # Set up Signer function prototypes
656
656
  _setup_function(
@@ -889,33 +889,51 @@ class _StringContainer:
889
889
  self._data_dir_str = ""
890
890
 
891
891
 
892
+ if sys.implementation.name == "cpython":
893
+ _PyMemoryView_FromMemory = ctypes.pythonapi.PyMemoryView_FromMemory
894
+ _PyMemoryView_FromMemory.restype = ctypes.py_object
895
+ _PyMemoryView_FromMemory.argtypes = (
896
+ ctypes.c_void_p, ctypes.c_ssize_t, ctypes.c_int)
897
+ _PyBUF_WRITE = 0x200
898
+
899
+ def _write_buf(address, length):
900
+ return _PyMemoryView_FromMemory(address, length, _PyBUF_WRITE)
901
+ else:
902
+ def _write_buf(address, length):
903
+ return (ctypes.c_char * length).from_address(address)
904
+
905
+
892
906
  def _convert_to_py_string(value) -> str:
893
907
  if value is None:
894
908
  return ""
895
909
 
896
910
  py_string = ""
897
911
 
898
- # Validate pointer before casting and freeing
899
- if not isinstance(value, (int, ctypes.c_void_p)) or value == 0:
912
+ # Validate and normalize pointer before reading and freeing.
913
+ if isinstance(value, ctypes.c_void_p):
914
+ address = value.value
915
+ elif isinstance(value, int):
916
+ address = value
917
+ else:
918
+ return ""
919
+ if not address:
900
920
  return ""
901
921
 
902
922
  try:
903
- ptr = ctypes.cast(value, ctypes.c_char_p)
923
+ raw = ctypes.string_at(address)
904
924
 
905
- # Only if we got a valid pointer with valid content
906
- if ptr and ptr.value is not None:
925
+ try:
926
+ py_string = raw.decode('utf-8', errors='strict')
927
+ except Exception:
928
+ py_string = ""
929
+ finally:
930
+ # Only free if we have a valid pointer
907
931
  try:
908
- py_string = ptr.value.decode('utf-8', errors='strict')
932
+ _lib.c2pa_string_free(value)
909
933
  except Exception:
910
- py_string = ""
911
- finally:
912
- # Only free if we have a valid pointer
913
- try:
914
- _lib.c2pa_string_free(value)
915
- except Exception:
916
- # Ignore clean up issues
917
- pass
918
- except (ctypes.ArgumentError, TypeError, ValueError):
934
+ # Ignore clean up issues
935
+ pass
936
+ except (ctypes.ArgumentError, TypeError, ValueError, OSError):
919
937
  # Invalid pointer type or value
920
938
  return ""
921
939
 
@@ -1003,8 +1021,7 @@ def _parse_operation_result_for_error(
1003
1021
  if check_error:
1004
1022
  error = _lib.c2pa_error()
1005
1023
  if error:
1006
- error_str = ctypes.cast(
1007
- error, ctypes.c_char_p).value.decode('utf-8')
1024
+ error_str = ctypes.string_at(error).decode('utf-8')
1008
1025
  _lib.c2pa_string_free(error)
1009
1026
  _raise_typed_c2pa_error(error_str)
1010
1027
  return None
@@ -1191,208 +1208,6 @@ def _get_mime_type_from_path(path: Union[str, Path]) -> str:
1191
1208
  return mime_type
1192
1209
 
1193
1210
 
1194
- def read_ingredient_file(
1195
- path: Union[str, Path], data_dir: Union[str, Path]) -> str:
1196
- """Read a file as C2PA ingredient (deprecated).
1197
- This creates the JSON string that would be used as the ingredient JSON.
1198
-
1199
- .. deprecated:: 0.11.0
1200
- This function is deprecated and will be removed in a future version.
1201
- To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class.
1202
- To add ingredients to a manifest,
1203
- use :meth:`c2pa.c2pa.Builder.add_ingredient` instead.
1204
-
1205
- Args:
1206
- path: Path to the file to read
1207
- data_dir: Directory to write binary resources to
1208
-
1209
- Returns:
1210
- The ingredient as a JSON string
1211
-
1212
- Raises:
1213
- C2paError: If there was an error reading the file
1214
- """
1215
- warnings.warn(
1216
- "The read_ingredient_file function is deprecated and will be "
1217
- "removed in a future version. Please use Reader(path).json() for "
1218
- "reading C2PA metadata instead, or "
1219
- "Builder.add_ingredient(json, format, stream) to add ingredients "
1220
- "to a manifest.",
1221
- DeprecationWarning,
1222
- stacklevel=2,
1223
- )
1224
-
1225
- _clear_error_state()
1226
-
1227
- container = _StringContainer()
1228
-
1229
- container._path_str = str(path).encode('utf-8')
1230
- container._data_dir_str = str(data_dir).encode('utf-8')
1231
-
1232
- result = _lib.c2pa_read_ingredient_file(
1233
- container._path_str, container._data_dir_str)
1234
-
1235
- _check_ffi_operation_result(
1236
- result, "Error reading ingredient file {}".format(path))
1237
-
1238
- return _convert_to_py_string(result)
1239
-
1240
-
1241
- def read_file(path: Union[str, Path],
1242
- data_dir: Union[str, Path]) -> str:
1243
- """Read a C2PA manifest from a file (deprecated).
1244
-
1245
- .. deprecated:: 0.10.0
1246
- This function is deprecated and will be removed in a future version.
1247
- To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class.
1248
-
1249
- Args:
1250
- path: Path to the file to read
1251
- data_dir: Directory to write binary resources to
1252
-
1253
- Returns:
1254
- The manifest as a JSON string
1255
-
1256
- Raises:
1257
- C2paError: If there was an error reading the file
1258
- """
1259
- warnings.warn(
1260
- "The read_file function is deprecated and will be removed in a "
1261
- "future version. Please use the Reader class for reading C2PA "
1262
- "metadata instead.",
1263
- DeprecationWarning,
1264
- stacklevel=2,
1265
- )
1266
-
1267
- _clear_error_state()
1268
-
1269
- container = _StringContainer()
1270
-
1271
- container._path_str = str(path).encode('utf-8')
1272
- container._data_dir_str = str(data_dir).encode('utf-8')
1273
-
1274
- result = _lib.c2pa_read_file(container._path_str, container._data_dir_str)
1275
- _check_ffi_operation_result(
1276
- result, "Error during read of manifest from file {}".format(path))
1277
-
1278
- return _convert_to_py_string(result)
1279
-
1280
-
1281
- @overload
1282
- def sign_file(
1283
- source_path: Union[str, Path],
1284
- dest_path: Union[str, Path],
1285
- manifest: str,
1286
- signer_info: C2paSignerInfo,
1287
- return_manifest_as_bytes: bool = False
1288
- ) -> Union[str, bytes]:
1289
- """Sign a file with a C2PA manifest using signer info.
1290
- """
1291
- ...
1292
-
1293
-
1294
- @overload
1295
- def sign_file(
1296
- source_path: Union[str, Path],
1297
- dest_path: Union[str, Path],
1298
- manifest: str,
1299
- signer: 'Signer',
1300
- return_manifest_as_bytes: bool = False
1301
- ) -> Union[str, bytes]:
1302
- """Sign a file with a C2PA manifest using a signer.
1303
- """
1304
- ...
1305
-
1306
-
1307
- def sign_file(
1308
- source_path: Union[str, Path],
1309
- dest_path: Union[str, Path],
1310
- manifest: str,
1311
- signer_or_info: Union[C2paSignerInfo, 'Signer'],
1312
- return_manifest_as_bytes: bool = False
1313
- ) -> Union[str, bytes]:
1314
- """Sign a file with a C2PA manifest (deprecated).
1315
- For now, this function is left here to provide a backwards-compatible API.
1316
-
1317
- .. deprecated:: 0.13.0
1318
- This function is deprecated and will be removed in a future version.
1319
- Use :meth:`Builder.sign` instead.
1320
-
1321
- Args:
1322
- source_path: Path to the source file. We will attempt
1323
- to guess the mimetype of the source file based on
1324
- the extension.
1325
- dest_path: Path to write the signed file to
1326
- manifest: The manifest JSON string
1327
- signer_or_info: Either a signer configuration or a signer object
1328
- return_manifest_as_bytes: If True, return manifest bytes instead
1329
- of JSON string
1330
-
1331
- Returns:
1332
- The signed manifest as a JSON string or bytes, depending
1333
- on return_manifest_as_bytes
1334
-
1335
- Raises:
1336
- C2paError: If there was an error signing the file
1337
- C2paError.Encoding: If any of the string inputs contain
1338
- invalid UTF-8 characters
1339
- C2paError.NotSupported: If the file type cannot be determined
1340
- """
1341
-
1342
- warnings.warn(
1343
- "The sign_file function is deprecated and will be removed in a "
1344
- "future version. Please use the Builder object and Builder.sign() "
1345
- "instead.",
1346
- DeprecationWarning,
1347
- stacklevel=2,
1348
- )
1349
-
1350
- _clear_error_state()
1351
-
1352
- try:
1353
- # Determine if we have a signer or signer info
1354
- if isinstance(signer_or_info, C2paSignerInfo):
1355
- signer = Signer.from_info(signer_or_info)
1356
- own_signer = True
1357
- else:
1358
- signer = signer_or_info
1359
- own_signer = False
1360
-
1361
- # Create a builder from the manifest
1362
- builder = Builder(manifest)
1363
-
1364
- manifest_bytes = builder.sign_file(
1365
- source_path,
1366
- dest_path,
1367
- signer
1368
- )
1369
-
1370
- if return_manifest_as_bytes:
1371
- return manifest_bytes
1372
- else:
1373
- # Read the signed manifest from the destination file
1374
- with Reader(dest_path) as reader:
1375
- return reader.json()
1376
-
1377
- except Exception as e:
1378
- # Clean up destination file if it exists and there was an error
1379
- if os.path.exists(dest_path):
1380
- try:
1381
- os.remove(dest_path)
1382
- except OSError:
1383
- logger.warning("Failed to remove destination file")
1384
- pass # Ignore cleanup errors
1385
-
1386
- # Re-raise the error
1387
- raise C2paError(f"Error signing file: {str(e)}") from e
1388
- finally:
1389
- # Ensure resources are cleaned up
1390
- if 'builder' in locals():
1391
- builder.close()
1392
- if 'signer' in locals() and own_signer:
1393
- signer.close()
1394
-
1395
-
1396
1211
  class ContextProvider(ABC):
1397
1212
  """Abstract base class for types that provide a C2PA context.
1398
1213
 
@@ -1827,10 +1642,21 @@ class Stream:
1827
1642
  readinto = getattr(stream, "readinto", None)
1828
1643
  if readinto is not None:
1829
1644
  # Most streams have readinto
1830
- buf = (ctypes.c_char * length).from_address(
1831
- ctypes.addressof(data.contents))
1832
- n = readinto(buf)
1833
- return n if n else 0
1645
+ buf = _write_buf(
1646
+ ctypes.addressof(data.contents), length)
1647
+ try:
1648
+ n = readinto(buf)
1649
+ finally:
1650
+ release = getattr(buf, "release", None)
1651
+ if release is not None:
1652
+ release()
1653
+ if not n:
1654
+ return 0
1655
+ if n > length:
1656
+ raise ValueError(
1657
+ f"readinto returned {n} bytes but buffer length is {length}"
1658
+ )
1659
+ return n
1834
1660
 
1835
1661
  # Fallback for streams without readinto.
1836
1662
  buffer = stream.read(length)
@@ -1945,6 +1771,7 @@ class Stream:
1945
1771
  raise Exception("Failed to create stream: {}".format(error))
1946
1772
 
1947
1773
  self._initialized = True
1774
+ record_owner_pid(self)
1948
1775
 
1949
1776
  def __enter__(self):
1950
1777
  """Context manager entry."""
@@ -1963,6 +1790,8 @@ class Stream:
1963
1790
  hasn't been explicitly closed.
1964
1791
  """
1965
1792
  try:
1793
+ if is_foreign_process(self):
1794
+ return
1966
1795
  # Only cleanup if not already closed and we have a valid stream
1967
1796
  if hasattr(self, '_closed') and not self._closed:
1968
1797
  stream = self._stream
@@ -1993,6 +1822,10 @@ class Stream:
1993
1822
  """
1994
1823
  if self._closed:
1995
1824
  return
1825
+ if is_foreign_process(self):
1826
+ self._closed = True
1827
+ self._initialized = False
1828
+ return
1996
1829
 
1997
1830
  try:
1998
1831
  # Clean up stream first as it depends on callbacks
@@ -3411,52 +3244,6 @@ class Builder(ManagedResource):
3411
3244
  Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error"),
3412
3245
  check=lambda r: r != 0)
3413
3246
 
3414
- def add_ingredient_from_file_path(
3415
- self,
3416
- ingredient_json: Union[str, dict],
3417
- format: str,
3418
- filepath: Union[str, Path]):
3419
- """Add an ingredient from a file path to the builder (deprecated).
3420
- This is a legacy method.
3421
-
3422
- .. deprecated:: 0.13.0
3423
- This method is deprecated and will be removed in a future version.
3424
- Use :meth:`add_ingredient` with a file stream instead.
3425
-
3426
- Args:
3427
- ingredient_json: The JSON ingredient definition
3428
- (either a JSON string or a dictionary)
3429
- format: The MIME type or extension of the ingredient
3430
- filepath: The path to the file containing the ingredient data
3431
- (can be a string or Path object)
3432
-
3433
- Raises:
3434
- C2paError: If there was an error adding the ingredient
3435
- C2paError.Encoding: If the ingredient JSON or format
3436
- contains invalid UTF-8 characters
3437
- FileNotFoundError: If the file at the specified path does not exist
3438
- """
3439
- warnings.warn(
3440
- "add_ingredient_from_file_path is deprecated and will "
3441
- "be removed in a future version. Use add_ingredient "
3442
- "with a file stream instead.",
3443
- DeprecationWarning,
3444
- stacklevel=2,
3445
- )
3446
-
3447
- try:
3448
- # Convert Path object to string if necessary
3449
- filepath_str = str(filepath)
3450
-
3451
- # Does the stream handling to use add_ingredient_from_stream
3452
- with open(filepath_str, 'rb') as file_stream:
3453
- self.add_ingredient_from_stream(
3454
- ingredient_json, format, file_stream)
3455
- except FileNotFoundError as e:
3456
- raise C2paError.FileNotFound(f"File not found: {filepath}") from e
3457
- except Exception as e:
3458
- raise C2paError.Other(f"Could not add ingredient: {e}") from e
3459
-
3460
3247
  def add_action(self, action_json: Union[str, dict]) -> None:
3461
3248
  """Add an action to the builder, that will be placed
3462
3249
  in the actions assertion array in the generated manifest.
@@ -3991,9 +3778,6 @@ __all__ = [
3991
3778
  'Builder',
3992
3779
  'Signer',
3993
3780
  'load_settings',
3994
- 'read_file',
3995
- 'read_ingredient_file',
3996
- 'sign_file',
3997
3781
  'format_embeddable',
3998
3782
  'version',
3999
3783
  'sdk_version'
@@ -37,7 +37,8 @@ def get_platform_identifier() -> str:
37
37
  - universal-apple-darwin (for Mac universal)
38
38
  - aarch64-apple-darwin (for Mac ARM64)
39
39
  - x86_64-apple-darwin (for Mac x86_64)
40
- - x86_64-pc-windows-msvc (for Windows 64-bit)
40
+ - x86_64-pc-windows-msvc (for Windows x64)
41
+ - aarch64-pc-windows-msvc (for Windows ARM64)
41
42
  - x86_64-unknown-linux-gnu (for Linux 64-bit)
42
43
  - aarch64-unknown-linux-gnu (for Linux ARM)
43
44
  """
@@ -54,6 +55,8 @@ def get_platform_identifier() -> str:
54
55
  else:
55
56
  return "universal-apple-darwin"
56
57
  elif system == "windows":
58
+ if _get_architecture() in [CPUArchitecture.ARM64.value, CPUArchitecture.AARCH64.value]:
59
+ return "aarch64-pc-windows-msvc"
57
60
  return "x86_64-pc-windows-msvc"
58
61
  elif system == "linux":
59
62
  if _get_architecture() in [CPUArchitecture.ARM64.value, CPUArchitecture.AARCH64.value]:
@@ -81,7 +84,7 @@ def _get_architecture() -> str:
81
84
  elif sys.platform == "win32":
82
85
  # win32 will cover all Windows versions
83
86
  # (the 32 is a historical quirk)
84
- return platform.machine()
87
+ return platform.machine().lower()
85
88
  else:
86
89
  raise RuntimeError(f"Unsupported platform: {sys.platform}")
87
90
 
@@ -296,3 +299,28 @@ def dynamically_load_library(
296
299
  raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
297
300
 
298
301
  return c2pa_lib
302
+
303
+
304
+ def record_owner_pid(obj):
305
+ """Keep the PID that created this native-handle wrapper
306
+ (call from __init__ as needed).
307
+ """
308
+ obj._owner_pid = os.getpid()
309
+
310
+
311
+ def is_foreign_process(obj):
312
+ """Return True when this object is being finalized in a forked child that did not
313
+ create it. After a multithreaded fork(), native mutexes may be held by threads
314
+ absent in the child -> any lock() call deadlocks. Callers must skip native frees
315
+ when this returns True.
316
+
317
+ Skipping the free does not cause a cumulative leak. If the child calls exec() the
318
+ address space is replaced entirely; if it exits, the OS reclaims all process memory.
319
+ Even a long-lived fork child (e.g. a multiprocessing fork-start worker) leaks at most
320
+ the objects inherited at fork time — a one-time, bounded amount reclaimed when the
321
+ child terminates. Objects the child creates itself carry the child's PID and are
322
+ freed normally.
323
+
324
+ Defensive default: if _owner_pid was never set, returns False (no regression)."""
325
+ owner = getattr(obj, '_owner_pid', None)
326
+ return owner is not None and owner != os.getpid()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.32.14
3
+ Version: 0.35.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -31,7 +31,7 @@ warnings.simplefilter("ignore", category=DeprecationWarning)
31
31
 
32
32
  from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType
33
33
  from c2pa import Settings, Context, ContextBuilder, ContextProvider
34
- from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable
34
+ from c2pa.c2pa import Stream, LifecycleState, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable
35
35
 
36
36
 
37
37
  PROJECT_PATH = os.getcwd()
@@ -4787,9 +4787,6 @@ class TestStream(unittest.TestCase):
4787
4787
  class TestLegacyAPI(unittest.TestCase):
4788
4788
  def setUp(self):
4789
4789
  # Filter specific deprecation warnings for legacy API tests
4790
- warnings.filterwarnings("ignore", message="The read_file function is deprecated")
4791
- warnings.filterwarnings("ignore", message="The sign_file function is deprecated")
4792
- warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated")
4793
4790
  warnings.filterwarnings("ignore", message="The create_signer function is deprecated")
4794
4791
  warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated")
4795
4792
  warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated")
@@ -4863,489 +4860,6 @@ class TestLegacyAPI(unittest.TestCase):
4863
4860
  if os.path.exists(self.temp_data_dir):
4864
4861
  shutil.rmtree(self.temp_data_dir)
4865
4862
 
4866
- def test_read_ingredient_file(self):
4867
- """Test reading a C2PA ingredient from a file."""
4868
- # Test reading ingredient from file with data_dir
4869
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
4870
- os.makedirs(temp_data_dir, exist_ok=True)
4871
-
4872
- ingredient_json_with_dir = read_ingredient_file(self.testPath, temp_data_dir)
4873
-
4874
- # Verify some fields
4875
- ingredient_data = json.loads(ingredient_json_with_dir)
4876
- self.assertEqual(ingredient_data["title"], DEFAULT_TEST_FILE_NAME)
4877
- self.assertEqual(ingredient_data["format"], "image/jpeg")
4878
- self.assertIn("thumbnail", ingredient_data)
4879
-
4880
- def test_read_ingredient_file_who_has_no_manifest(self):
4881
- """Test reading a C2PA ingredient from a file."""
4882
- # Test reading ingredient from file with data_dir
4883
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
4884
- os.makedirs(temp_data_dir, exist_ok=True)
4885
-
4886
- # Load settings first, before they need to be used
4887
- load_settings('{"builder": { "thumbnail": {"enabled": false}}}')
4888
-
4889
- ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir)
4890
-
4891
- # Verify some fields
4892
- ingredient_data = json.loads(ingredient_json_with_dir)
4893
- self.assertEqual(ingredient_data["title"], INGREDIENT_TEST_FILE_NAME)
4894
- self.assertEqual(ingredient_data["format"], "image/jpeg")
4895
- self.assertNotIn("thumbnail", ingredient_data)
4896
-
4897
- # Reset setting
4898
- load_settings('{"builder": { "thumbnail": {"enabled": true}}}')
4899
-
4900
- def test_compare_read_ingredient_file_with_builder_added_ingredient(self):
4901
- """Test reading a C2PA ingredient from a file."""
4902
- # Test reading ingredient from file with data_dir
4903
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
4904
- os.makedirs(temp_data_dir, exist_ok=True)
4905
-
4906
- ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir)
4907
-
4908
- # Ingredient fields from read_ingredient_file
4909
- ingredient_data = json.loads(ingredient_json_with_dir)
4910
-
4911
- # Compare with ingredient added by Builder
4912
- builder = Builder.from_json(self.manifestDefinition)
4913
- # Only the title is needed (filename), since title not extracted or guessed from filename
4914
- ingredient_json = '{ "title" : "A.jpg" }'
4915
- with open(self.testPath2, 'rb') as f:
4916
- builder.add_ingredient(ingredient_json, "image/jpeg", f)
4917
-
4918
- with open(self.testPath2, "rb") as file:
4919
- output = io.BytesIO(bytearray())
4920
- builder.sign(self.signer, "image/jpeg", file, output)
4921
- output.seek(0)
4922
-
4923
- # Get ingredient fields from signed manifest
4924
- reader = Reader("image/jpeg", output)
4925
- json_data = reader.json()
4926
- manifest_data = json.loads(json_data)
4927
- active_manifest_id = manifest_data["active_manifest"]
4928
- active_manifest = manifest_data["manifests"][active_manifest_id]
4929
- only_ingredient = active_manifest["ingredients"][0]
4930
-
4931
- self.assertEqual(ingredient_data["title"], only_ingredient["title"])
4932
- self.assertEqual(ingredient_data["format"], only_ingredient["format"])
4933
- self.assertEqual(ingredient_data["document_id"], only_ingredient["document_id"])
4934
- self.assertEqual(ingredient_data["instance_id"], only_ingredient["instance_id"])
4935
- self.assertEqual(ingredient_data["relationship"], only_ingredient["relationship"])
4936
-
4937
- def test_read_file(self):
4938
- """Test reading a C2PA ingredient from a file."""
4939
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
4940
- os.makedirs(temp_data_dir, exist_ok=True)
4941
-
4942
- # self.testPath has C2PA metadata to read
4943
- file_json_with_dir = read_file(self.testPath, temp_data_dir)
4944
-
4945
- # Parse the JSON and verify specific fields
4946
- file_data = json.loads(file_json_with_dir)
4947
- expected_manifest_id = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e"
4948
-
4949
- # Verify some fields
4950
- self.assertEqual(file_data["active_manifest"], expected_manifest_id)
4951
- self.assertIn("manifests", file_data)
4952
- self.assertIn(expected_manifest_id, file_data["manifests"])
4953
-
4954
- def test_sign_file(self):
4955
- """Test signing a file with C2PA manifest."""
4956
- # Set up test paths
4957
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
4958
- os.makedirs(temp_data_dir, exist_ok=True)
4959
- output_path = os.path.join(temp_data_dir, "signed_output.jpg")
4960
-
4961
- # Load test certificates and key
4962
- with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file:
4963
- certs = cert_file.read()
4964
- with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file:
4965
- key = key_file.read()
4966
-
4967
- # Create signer info
4968
- signer_info = C2paSignerInfo(
4969
- alg=b"es256",
4970
- sign_cert=certs,
4971
- private_key=key,
4972
- ta_url=b"http://timestamp.digicert.com"
4973
- )
4974
-
4975
- # Create a simple manifest
4976
- manifest = {
4977
- "claim_generator": "python_internals_test",
4978
- "claim_generator_info": [{
4979
- "name": "python_internals_test",
4980
- "version": "0.0.1",
4981
- }],
4982
- # Claim version has become mandatory for signing v1 claims
4983
- "claim_version": 1,
4984
- "format": "image/jpeg",
4985
- "title": "Python Test Signed Image",
4986
- "ingredients": [],
4987
- "assertions": [
4988
- {
4989
- "label": "c2pa.actions",
4990
- "data": {
4991
- "actions": [
4992
- {
4993
- "action": "c2pa.opened"
4994
- }
4995
- ]
4996
- }
4997
- }
4998
- ]
4999
- }
5000
-
5001
- # Convert manifest to JSON string
5002
- manifest_json = json.dumps(manifest)
5003
-
5004
- try:
5005
- sign_file(
5006
- self.testPath,
5007
- output_path,
5008
- manifest_json,
5009
- signer_info
5010
- )
5011
-
5012
- finally:
5013
- # Clean up
5014
- if os.path.exists(output_path):
5015
- os.remove(output_path)
5016
-
5017
- def test_sign_file_does_not_exist_errors(self):
5018
- """Test signing a file with C2PA manifest."""
5019
- # Set up test paths
5020
- temp_data_dir = os.path.join(self.data_dir, "temp_data")
5021
- os.makedirs(temp_data_dir, exist_ok=True)
5022
- output_path = os.path.join(temp_data_dir, "signed_output.jpg")
5023
-
5024
- # Load test certificates and key
5025
- with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file:
5026
- certs = cert_file.read()
5027
- with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file:
5028
- key = key_file.read()
5029
-
5030
- # Create signer info
5031
- signer_info = C2paSignerInfo(
5032
- alg=b"es256",
5033
- sign_cert=certs,
5034
- private_key=key,
5035
- ta_url=b"http://timestamp.digicert.com"
5036
- )
5037
-
5038
- # Create a simple manifest
5039
- manifest = {
5040
- "claim_generator": "python_internals_test",
5041
- "claim_generator_info": [{
5042
- "name": "python_internals_test",
5043
- "version": "0.0.1",
5044
- }],
5045
- # Claim version has become mandatory for signing v1 claims
5046
- "claim_version": 1,
5047
- "format": "image/jpeg",
5048
- "title": "Python Test Signed Image",
5049
- "ingredients": [],
5050
- "assertions": [
5051
- {
5052
- "label": "c2pa.actions",
5053
- "data": {
5054
- "actions": [
5055
- {
5056
- "action": "c2pa.opened"
5057
- }
5058
- ]
5059
- }
5060
- }
5061
- ]
5062
- }
5063
-
5064
- # Convert manifest to JSON string
5065
- manifest_json = json.dumps(manifest)
5066
-
5067
- try:
5068
- with self.assertRaises(Error):
5069
- sign_file(
5070
- "this-file-does-not-exist",
5071
- output_path,
5072
- manifest_json,
5073
- signer_info
5074
- )
5075
-
5076
- finally:
5077
- # Clean up
5078
- if os.path.exists(output_path):
5079
- os.remove(output_path)
5080
-
5081
- def test_builder_sign_with_ingredient_from_file(self):
5082
- """Test Builder class operations with an ingredient added from file path."""
5083
-
5084
- builder = Builder.from_json(self.manifestDefinition)
5085
-
5086
- # Test adding ingredient from file path
5087
- ingredient_json = '{"title": "Test Ingredient From File"}'
5088
- # Suppress the specific deprecation warning for this test, as this is a legacy method
5089
- with warnings.catch_warnings():
5090
- warnings.simplefilter("ignore", DeprecationWarning)
5091
- builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath3)
5092
-
5093
- with open(self.testPath2, "rb") as file:
5094
- output = io.BytesIO(bytearray())
5095
- builder.sign(self.signer, "image/jpeg", file, output)
5096
- output.seek(0)
5097
- reader = Reader("image/jpeg", output)
5098
- json_data = reader.json()
5099
- manifest_data = json.loads(json_data)
5100
-
5101
- # Verify active manifest exists
5102
- self.assertIn("active_manifest", manifest_data)
5103
- active_manifest_id = manifest_data["active_manifest"]
5104
-
5105
- # Verify active manifest object exists
5106
- self.assertIn("manifests", manifest_data)
5107
- self.assertIn(active_manifest_id, manifest_data["manifests"])
5108
- active_manifest = manifest_data["manifests"][active_manifest_id]
5109
-
5110
- # Verify ingredients array exists in active manifest
5111
- self.assertIn("ingredients", active_manifest)
5112
- self.assertIsInstance(active_manifest["ingredients"], list)
5113
- self.assertTrue(len(active_manifest["ingredients"]) > 0)
5114
-
5115
- # Verify the first ingredient's title matches what we set
5116
- first_ingredient = active_manifest["ingredients"][0]
5117
- self.assertEqual(first_ingredient["title"], "Test Ingredient From File")
5118
-
5119
- builder.close()
5120
-
5121
- def test_builder_sign_with_ingredient_dict_from_file(self):
5122
- """Test Builder class operations with an ingredient added from file path using a dictionary."""
5123
-
5124
- builder = Builder.from_json(self.manifestDefinition)
5125
-
5126
- # Test adding ingredient from file path with a dictionary
5127
- ingredient_dict = {"title": "Test Ingredient From File"}
5128
- # Suppress the specific deprecation warning for this test, as this is a legacy method
5129
- with warnings.catch_warnings():
5130
- warnings.simplefilter("ignore", DeprecationWarning)
5131
- builder.add_ingredient_from_file_path(ingredient_dict, "image/jpeg", self.testPath3)
5132
-
5133
- with open(self.testPath2, "rb") as file:
5134
- output = io.BytesIO(bytearray())
5135
- builder.sign(self.signer, "image/jpeg", file, output)
5136
- output.seek(0)
5137
- reader = Reader("image/jpeg", output)
5138
- json_data = reader.json()
5139
- manifest_data = json.loads(json_data)
5140
-
5141
- # Verify active manifest exists
5142
- self.assertIn("active_manifest", manifest_data)
5143
- active_manifest_id = manifest_data["active_manifest"]
5144
-
5145
- # Verify active manifest object exists
5146
- self.assertIn("manifests", manifest_data)
5147
- self.assertIn(active_manifest_id, manifest_data["manifests"])
5148
- active_manifest = manifest_data["manifests"][active_manifest_id]
5149
-
5150
- # Verify ingredients array exists in active manifest
5151
- self.assertIn("ingredients", active_manifest)
5152
- self.assertIsInstance(active_manifest["ingredients"], list)
5153
- self.assertTrue(len(active_manifest["ingredients"]) > 0)
5154
-
5155
- # Verify the first ingredient's title matches what we set
5156
- first_ingredient = active_manifest["ingredients"][0]
5157
- self.assertEqual(first_ingredient["title"], "Test Ingredient From File")
5158
-
5159
- builder.close()
5160
-
5161
- def test_builder_add_ingredient_from_file_path(self):
5162
- """Test Builder class add_ingredient_from_file_path method."""
5163
-
5164
- # Suppress the specific deprecation warning for this test, as this is a legacy method
5165
- with warnings.catch_warnings():
5166
- warnings.simplefilter("ignore", DeprecationWarning)
5167
-
5168
- builder = Builder.from_json(self.manifestDefinition)
5169
-
5170
- # Test adding ingredient from file path
5171
- ingredient_json = '{"test": "ingredient_from_file_path"}'
5172
- builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath)
5173
-
5174
- builder.close()
5175
-
5176
- def test_builder_add_ingredient_from_file_path_not_found(self):
5177
- """Test Builder class add_ingredient_from_file_path method."""
5178
-
5179
- # Suppress the specific deprecation warning for this test, as this is a legacy method
5180
- with warnings.catch_warnings():
5181
- warnings.simplefilter("ignore", DeprecationWarning)
5182
-
5183
- builder = Builder.from_json(self.manifestDefinition)
5184
-
5185
- # Test adding ingredient from file path
5186
- ingredient_json = '{"test": "ingredient_from_file_path"}'
5187
-
5188
- with self.assertRaises(Error.FileNotFound):
5189
- builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "this-file-path-does-not-exist")
5190
-
5191
- def test_sign_file_using_callback_signer_overloads(self):
5192
- """Test signing a file using the sign_file function with a Signer object."""
5193
- # Create a temporary directory for the test
5194
- temp_dir = tempfile.mkdtemp()
5195
-
5196
- try:
5197
- # Create a temporary output file path
5198
- output_path = os.path.join(temp_dir, "signed_output_callback.jpg")
5199
-
5200
- # Create signer with callback
5201
- signer = Signer.from_callback(
5202
- callback=self.callback_signer_es256,
5203
- alg=SigningAlg.ES256,
5204
- certs=self.certs.decode('utf-8'),
5205
- tsa_url="http://timestamp.digicert.com"
5206
- )
5207
-
5208
- # Overload that returns a JSON string
5209
- result_json = sign_file(
5210
- self.testPath,
5211
- output_path,
5212
- self.manifestDefinition,
5213
- signer,
5214
- False
5215
- )
5216
-
5217
- # Verify the output file was created
5218
- self.assertTrue(os.path.exists(output_path))
5219
-
5220
- # Verify the result is JSON
5221
- self.assertIsInstance(result_json, str)
5222
- self.assertGreater(len(result_json), 0)
5223
-
5224
- manifest_data = json.loads(result_json)
5225
- self.assertIn("manifests", manifest_data)
5226
- self.assertIn("active_manifest", manifest_data)
5227
-
5228
- output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg")
5229
- # Overload that returns bytes
5230
- result_bytes = sign_file(
5231
- self.testPath,
5232
- output_path_bytes,
5233
- self.manifestDefinition,
5234
- signer,
5235
- True
5236
- )
5237
-
5238
- # Verify the output file was created
5239
- self.assertTrue(os.path.exists(output_path_bytes))
5240
-
5241
- # Verify the result is bytes
5242
- self.assertIsInstance(result_bytes, bytes)
5243
- self.assertGreater(len(result_bytes), 0)
5244
-
5245
- # Read the signed file and verify the manifest contains expected content
5246
- with open(output_path, "rb") as file:
5247
- reader = Reader("image/jpeg", file)
5248
- file_manifest_json = reader.json()
5249
- self.assertIn("Python Test", file_manifest_json)
5250
-
5251
- finally:
5252
- shutil.rmtree(temp_dir)
5253
-
5254
- def test_sign_file_overloads(self):
5255
- """Test that the overloaded sign_file function works with both parameter types."""
5256
- # Create a temporary directory for the test
5257
- temp_dir = tempfile.mkdtemp()
5258
- try:
5259
- # Test with C2paSignerInfo
5260
- output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg")
5261
-
5262
- # Load test certificates and key
5263
- with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file:
5264
- certs = cert_file.read()
5265
- with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file:
5266
- key = key_file.read()
5267
-
5268
- # Create signer info
5269
- signer_info = C2paSignerInfo(
5270
- alg=b"es256",
5271
- sign_cert=certs,
5272
- private_key=key,
5273
- ta_url=b"http://timestamp.digicert.com"
5274
- )
5275
-
5276
- # Test with C2paSignerInfo parameter - JSON return
5277
- result_1 = sign_file(
5278
- self.testPath,
5279
- output_path_1,
5280
- self.manifestDefinition,
5281
- signer_info,
5282
- False
5283
- )
5284
-
5285
- self.assertIsInstance(result_1, str)
5286
- self.assertTrue(os.path.exists(output_path_1))
5287
-
5288
- # Test with C2paSignerInfo parameter - bytes return
5289
- output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg")
5290
- result_1_bytes = sign_file(
5291
- self.testPath,
5292
- output_path_1_bytes,
5293
- self.manifestDefinition,
5294
- signer_info,
5295
- True
5296
- )
5297
-
5298
- self.assertIsInstance(result_1_bytes, bytes)
5299
- self.assertTrue(os.path.exists(output_path_1_bytes))
5300
-
5301
- # Test with Signer object
5302
- output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg")
5303
-
5304
- # Create a signer from the signer info
5305
- signer = Signer.from_info(signer_info)
5306
-
5307
- # Test with Signer parameter - JSON return
5308
- result_2 = sign_file(
5309
- self.testPath,
5310
- output_path_2,
5311
- self.manifestDefinition,
5312
- signer,
5313
- False
5314
- )
5315
-
5316
- self.assertIsInstance(result_2, str)
5317
- self.assertTrue(os.path.exists(output_path_2))
5318
-
5319
- # Test with Signer parameter - bytes return
5320
- output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg")
5321
- result_2_bytes = sign_file(
5322
- self.testPath,
5323
- output_path_2_bytes,
5324
- self.manifestDefinition,
5325
- signer,
5326
- True
5327
- )
5328
-
5329
- self.assertIsInstance(result_2_bytes, bytes)
5330
- self.assertTrue(os.path.exists(output_path_2_bytes))
5331
-
5332
- # Both JSON results should be similar (same manifest structure)
5333
- manifest_1 = json.loads(result_1)
5334
- manifest_2 = json.loads(result_2)
5335
-
5336
- self.assertIn("manifests", manifest_1)
5337
- self.assertIn("manifests", manifest_2)
5338
- self.assertIn("active_manifest", manifest_1)
5339
- self.assertIn("active_manifest", manifest_2)
5340
-
5341
- # Both bytes results should be non-empty
5342
- self.assertGreater(len(result_1_bytes), 0)
5343
- self.assertGreater(len(result_2_bytes), 0)
5344
-
5345
- finally:
5346
- # Clean up the temporary directory
5347
- shutil.rmtree(temp_dir)
5348
-
5349
4863
  def test_sign_file_callback_signer_reports_error(self):
5350
4864
  """Test signing a file using the sign_file method with a callback that reports an error."""
5351
4865
 
@@ -11,6 +11,7 @@
11
11
  # specific language governing permissions and limitations under
12
12
  # each license.
13
13
 
14
+ import ctypes
14
15
  import os
15
16
  import io
16
17
  import json
@@ -20,13 +21,172 @@ import concurrent.futures
20
21
  import time
21
22
  import asyncio
22
23
  import random
24
+ from unittest.mock import MagicMock, patch
23
25
 
24
26
  from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501
25
27
  from c2pa import Context, Settings
26
- from c2pa.c2pa import Stream
28
+ from c2pa.c2pa import ManagedResource, Stream, LifecycleState
29
+ from c2pa.lib import is_foreign_process, record_owner_pid
27
30
 
28
31
  PROJECT_PATH = os.getcwd()
29
32
  FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures")
33
+
34
+
35
+ class _ConcreteResource(ManagedResource):
36
+ """Minimal concrete subclass for testing ManagedResource cleanup."""
37
+
38
+
39
+ def _make_resource(pid_offset):
40
+ """Construct a ManagedResource-like object without triggering native init.
41
+
42
+ pid_offset=1 → simulates a forked child (foreign PID)
43
+ pid_offset=0 → same process (normal cleanup)
44
+ pid_offset=None → no _owner_pid stamp (backward-compat: no protection)
45
+ """
46
+ obj = object.__new__(_ConcreteResource)
47
+ obj._lifecycle_state = LifecycleState.ACTIVE
48
+ obj._handle = ctypes.c_void_p(1) # non-None, non-zero sentinel
49
+ if pid_offset is not None:
50
+ obj._owner_pid = os.getpid() + pid_offset
51
+ return obj
52
+
53
+
54
+ def _make_stream(pid_offset):
55
+ """Construct a Stream-like object without triggering native init."""
56
+ obj = object.__new__(Stream)
57
+ obj._closed = False
58
+ obj._initialized = True
59
+ obj._stream = MagicMock() # non-None stream handle
60
+ if pid_offset is not None:
61
+ obj._owner_pid = os.getpid() + pid_offset
62
+ return obj
63
+
64
+
65
+ class TestManagedResourceForkGuard(unittest.TestCase):
66
+ """Fork-safety unit tests for ManagedResource and Stream.
67
+
68
+ Verifies that the is_foreign_process() PID guard prevents native frees
69
+ from running in a forked child process (where native mutexes may be held
70
+ by threads that no longer exist, causing deadlock before exec()).
71
+
72
+ No real fork or auth credentials are required; PID mismatch is simulated
73
+ by setting _owner_pid = os.getpid() + 1.
74
+ """
75
+
76
+ def test_foreign_pid_skips_free(self):
77
+ """In a forked child (pid_offset=1), no native free should run."""
78
+ obj = _make_resource(pid_offset=1)
79
+ with patch('c2pa.c2pa._lib') as mock_lib:
80
+ obj._cleanup_resources()
81
+ mock_lib.c2pa_free.assert_not_called()
82
+
83
+ def test_own_pid_calls_free(self):
84
+ """In the owning process, cleanup must call c2pa_free normally."""
85
+ obj = _make_resource(pid_offset=0)
86
+ expected_handle = obj._handle
87
+ with patch('c2pa.c2pa._lib'):
88
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
89
+ obj._cleanup_resources()
90
+ mock_free.assert_called_once_with(expected_handle)
91
+
92
+ def test_no_stamp_calls_free(self):
93
+ """No _owner_pid (backward-compat) must NOT suppress cleanup."""
94
+ obj = _make_resource(pid_offset=None)
95
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
96
+ obj._cleanup_resources()
97
+ mock_free.assert_called_once()
98
+
99
+ def test_foreign_pid_leaves_state_unchanged(self):
100
+ """Guard returns early; lifecycle state stays ACTIVE (not CLOSED)."""
101
+ obj = _make_resource(pid_offset=1)
102
+ with patch('c2pa.c2pa._lib'):
103
+ obj._cleanup_resources()
104
+ self.assertEqual(obj._lifecycle_state, LifecycleState.ACTIVE)
105
+
106
+ def test_double_cleanup_is_idempotent(self):
107
+ """Second call is a no-op after successful first cleanup."""
108
+ obj = _make_resource(pid_offset=0)
109
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
110
+ obj._cleanup_resources()
111
+ obj._cleanup_resources()
112
+ mock_free.assert_called_once()
113
+
114
+ def test_foreign_pid_skips_release_via_del(self):
115
+ obj = _make_stream(pid_offset=1)
116
+ with patch('c2pa.c2pa._lib') as mock_lib:
117
+ obj.__del__()
118
+ mock_lib.c2pa_release_stream.assert_not_called()
119
+
120
+ def test_own_pid_releases_stream_via_del(self):
121
+ obj = _make_stream(pid_offset=0)
122
+ with patch('c2pa.c2pa._lib') as mock_lib:
123
+ obj.__del__()
124
+ mock_lib.c2pa_release_stream.assert_called_once()
125
+
126
+ def test_no_stamp_releases_stream_via_del(self):
127
+ obj = _make_stream(pid_offset=None)
128
+ with patch('c2pa.c2pa._lib') as mock_lib:
129
+ obj.__del__()
130
+ mock_lib.c2pa_release_stream.assert_called_once()
131
+
132
+ def test_already_closed_is_noop_via_del(self):
133
+ obj = _make_stream(pid_offset=0)
134
+ obj._closed = True
135
+ with patch('c2pa.c2pa._lib') as mock_lib:
136
+ obj.__del__()
137
+ mock_lib.c2pa_release_stream.assert_not_called()
138
+
139
+ def test_foreign_pid_skips_release_via_close(self):
140
+ obj = _make_stream(pid_offset=1)
141
+ with patch('c2pa.c2pa._lib') as mock_lib:
142
+ obj.close()
143
+ mock_lib.c2pa_release_stream.assert_not_called()
144
+ self.assertTrue(obj._closed)
145
+
146
+ def test_own_pid_releases_stream_via_close(self):
147
+ obj = _make_stream(pid_offset=0)
148
+ with patch('c2pa.c2pa._lib') as mock_lib:
149
+ obj.close()
150
+ mock_lib.c2pa_release_stream.assert_called_once()
151
+
152
+ def test_no_stamp_releases_stream_via_close(self):
153
+ obj = _make_stream(pid_offset=None)
154
+ with patch('c2pa.c2pa._lib') as mock_lib:
155
+ obj.close()
156
+ mock_lib.c2pa_release_stream.assert_called_once()
157
+
158
+ def test_already_closed_is_noop_via_close(self):
159
+ obj = _make_stream(pid_offset=0)
160
+ obj._closed = True
161
+ with patch('c2pa.c2pa._lib') as mock_lib:
162
+ obj.close()
163
+ mock_lib.c2pa_release_stream.assert_not_called()
164
+
165
+ def test_foreign_pid_close_marks_closed(self):
166
+ """close() in forked child must set _closed=True to prevent re-entry,
167
+ and _initialized=False so the public properties report a closed stream."""
168
+ obj = _make_stream(pid_offset=1)
169
+ with patch('c2pa.c2pa._lib'):
170
+ obj.close()
171
+ self.assertTrue(obj._closed)
172
+ self.assertFalse(obj._initialized)
173
+
174
+
175
+ class TestHelpers(unittest.TestCase):
176
+
177
+ def test_record_and_detect_own_pid(self):
178
+ obj = MagicMock()
179
+ record_owner_pid(obj)
180
+ self.assertFalse(is_foreign_process(obj))
181
+
182
+ def test_detect_foreign_pid(self):
183
+ obj = MagicMock()
184
+ obj._owner_pid = os.getpid() + 1
185
+ self.assertTrue(is_foreign_process(obj))
186
+
187
+ def test_no_stamp_not_foreign(self):
188
+ obj = MagicMock(spec=[]) # no _owner_pid attribute
189
+ self.assertFalse(is_foreign_process(obj))
30
190
  DEFAULT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "C.jpg")
31
191
  INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "A.jpg")
32
192
  ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "cloud.jpg")
File without changes
File without changes
File without changes
File without changes