c2pa-python 0.34.0__tar.gz → 0.35.1__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.
- {c2pa_python-0.34.0/src/c2pa_python.egg-info → c2pa_python-0.35.1}/PKG-INFO +2 -2
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/pyproject.toml +3 -2
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/c2pa.py +97 -9
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/lib.py +25 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1/src/c2pa_python.egg-info}/PKG-INFO +2 -2
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/tests/test_unit_tests.py +557 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/tests/test_unit_tests_threaded.py +161 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/LICENSE-APACHE +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/LICENSE-MIT +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/MANIFEST.in +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/README.md +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/requirements.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/scripts/download_artifacts.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/setup.cfg +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/setup.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/__init__.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/build.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/entry_points.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/requires.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: c2pa-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.1
|
|
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
|
-
Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
|
|
6
|
+
Maintainer-email: Gavin Peacock <gpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
|
|
7
7
|
License: MIT OR Apache-2.0
|
|
8
8
|
Project-URL: homepage, https://contentauthenticity.org
|
|
9
9
|
Project-URL: repository, https://github.com/contentauth/c2pa-python
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "c2pa-python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.35.1"
|
|
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" }
|
|
@@ -17,7 +17,8 @@ classifiers = [
|
|
|
17
17
|
"Operating System :: Microsoft :: Windows"
|
|
18
18
|
]
|
|
19
19
|
maintainers = [
|
|
20
|
-
{name = "Gavin Peacock", email = "gpeacock@adobe.com"}
|
|
20
|
+
{name = "Gavin Peacock", email = "gpeacock@adobe.com"},
|
|
21
|
+
{name = "Tania Mathern", email = "mathern@adobe.com"}
|
|
21
22
|
]
|
|
22
23
|
urls = {homepage = "https://contentauthenticity.org", repository = "https://github.com/contentauth/c2pa-python"}
|
|
23
24
|
dependencies = [
|
|
@@ -18,11 +18,12 @@ import logging
|
|
|
18
18
|
import sys
|
|
19
19
|
import os
|
|
20
20
|
import warnings
|
|
21
|
+
import weakref
|
|
21
22
|
from abc import ABC, abstractmethod
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Optional, Union, Callable, Any, overload
|
|
24
25
|
import io
|
|
25
|
-
from .lib import dynamically_load_library
|
|
26
|
+
from .lib import dynamically_load_library, record_owner_pid, is_foreign_process
|
|
26
27
|
import mimetypes
|
|
27
28
|
from itertools import count
|
|
28
29
|
|
|
@@ -65,6 +66,8 @@ _REQUIRED_FUNCTIONS = [
|
|
|
65
66
|
'c2pa_builder_add_ingredient_from_stream',
|
|
66
67
|
'c2pa_builder_add_action',
|
|
67
68
|
'c2pa_builder_to_archive',
|
|
69
|
+
'c2pa_builder_write_ingredient_archive',
|
|
70
|
+
'c2pa_builder_add_ingredient_from_archive',
|
|
68
71
|
'c2pa_builder_sign',
|
|
69
72
|
'c2pa_builder_sign_context',
|
|
70
73
|
'c2pa_builder_from_context',
|
|
@@ -238,6 +241,7 @@ class ManagedResource:
|
|
|
238
241
|
self._lifecycle_state = LifecycleState.UNINITIALIZED
|
|
239
242
|
self._handle = None
|
|
240
243
|
_clear_error_state()
|
|
244
|
+
record_owner_pid(self)
|
|
241
245
|
|
|
242
246
|
@staticmethod
|
|
243
247
|
def _free_native_ptr(ptr):
|
|
@@ -280,6 +284,8 @@ class ManagedResource:
|
|
|
280
284
|
def _cleanup_resources(self):
|
|
281
285
|
"""Release native resources idempotently."""
|
|
282
286
|
try:
|
|
287
|
+
if is_foreign_process(self):
|
|
288
|
+
return
|
|
283
289
|
if (
|
|
284
290
|
hasattr(self, '_lifecycle_state')
|
|
285
291
|
and self._lifecycle_state != LifecycleState.CLOSED
|
|
@@ -620,6 +626,15 @@ _setup_function(_lib.c2pa_builder_add_action,
|
|
|
620
626
|
_setup_function(_lib.c2pa_builder_to_archive,
|
|
621
627
|
[ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)],
|
|
622
628
|
ctypes.c_int)
|
|
629
|
+
_setup_function(_lib.c2pa_builder_write_ingredient_archive,
|
|
630
|
+
[ctypes.POINTER(C2paBuilder),
|
|
631
|
+
ctypes.c_char_p,
|
|
632
|
+
ctypes.POINTER(C2paStream)],
|
|
633
|
+
ctypes.c_int)
|
|
634
|
+
_setup_function(_lib.c2pa_builder_add_ingredient_from_archive,
|
|
635
|
+
[ctypes.POINTER(C2paBuilder),
|
|
636
|
+
ctypes.POINTER(C2paStream)],
|
|
637
|
+
ctypes.c_int)
|
|
623
638
|
_setup_function(_lib.c2pa_builder_sign,
|
|
624
639
|
[ctypes.POINTER(C2paBuilder),
|
|
625
640
|
ctypes.c_char_p,
|
|
@@ -1611,6 +1626,10 @@ class Stream:
|
|
|
1611
1626
|
|
|
1612
1627
|
self._file_like_stream = file_like_stream
|
|
1613
1628
|
|
|
1629
|
+
# Weakref breaks avoids references cycles:
|
|
1630
|
+
# Stream -> ctypes_cb -> closure -> Stream.
|
|
1631
|
+
_weak_self = weakref.ref(self)
|
|
1632
|
+
|
|
1614
1633
|
def read_callback(ctx, data, length):
|
|
1615
1634
|
"""Callback function for reading data from the Python stream.
|
|
1616
1635
|
|
|
@@ -1629,13 +1648,16 @@ class Stream:
|
|
|
1629
1648
|
Returns:
|
|
1630
1649
|
Number of bytes read, or -1 on error
|
|
1631
1650
|
"""
|
|
1632
|
-
|
|
1651
|
+
s = _weak_self()
|
|
1652
|
+
if s is None:
|
|
1653
|
+
return -1
|
|
1654
|
+
if not s._initialized or s._closed:
|
|
1633
1655
|
return -1
|
|
1634
1656
|
try:
|
|
1635
1657
|
if not data or length <= 0:
|
|
1636
1658
|
return -1
|
|
1637
1659
|
|
|
1638
|
-
stream =
|
|
1660
|
+
stream = s._file_like_stream
|
|
1639
1661
|
readinto = getattr(stream, "readinto", None)
|
|
1640
1662
|
if readinto is not None:
|
|
1641
1663
|
# Most streams have readinto
|
|
@@ -1686,8 +1708,11 @@ class Stream:
|
|
|
1686
1708
|
Returns:
|
|
1687
1709
|
New position in the stream, or -1 on error
|
|
1688
1710
|
"""
|
|
1689
|
-
|
|
1690
|
-
if
|
|
1711
|
+
s = _weak_self()
|
|
1712
|
+
if s is None:
|
|
1713
|
+
return -1
|
|
1714
|
+
file_stream = s._file_like_stream
|
|
1715
|
+
if not s._initialized or s._closed:
|
|
1691
1716
|
return -1
|
|
1692
1717
|
try:
|
|
1693
1718
|
# Fall back to tell() only for stream objects that do not
|
|
@@ -1715,13 +1740,16 @@ class Stream:
|
|
|
1715
1740
|
Returns:
|
|
1716
1741
|
Number of bytes written, or -1 on error
|
|
1717
1742
|
"""
|
|
1718
|
-
|
|
1743
|
+
s = _weak_self()
|
|
1744
|
+
if s is None:
|
|
1745
|
+
return -1
|
|
1746
|
+
if not s._initialized or s._closed:
|
|
1719
1747
|
return -1
|
|
1720
1748
|
try:
|
|
1721
1749
|
if not data or length <= 0:
|
|
1722
1750
|
return -1
|
|
1723
1751
|
|
|
1724
|
-
|
|
1752
|
+
s._file_like_stream.write(ctypes.string_at(data, length))
|
|
1725
1753
|
return length
|
|
1726
1754
|
except Exception:
|
|
1727
1755
|
return -1
|
|
@@ -1740,10 +1768,13 @@ class Stream:
|
|
|
1740
1768
|
Returns:
|
|
1741
1769
|
0 on success, -1 on error
|
|
1742
1770
|
"""
|
|
1743
|
-
|
|
1771
|
+
s = _weak_self()
|
|
1772
|
+
if s is None:
|
|
1773
|
+
return -1
|
|
1774
|
+
if not s._initialized or s._closed:
|
|
1744
1775
|
return -1
|
|
1745
1776
|
try:
|
|
1746
|
-
|
|
1777
|
+
s._file_like_stream.flush()
|
|
1747
1778
|
return 0
|
|
1748
1779
|
except Exception:
|
|
1749
1780
|
return -1
|
|
@@ -1768,6 +1799,7 @@ class Stream:
|
|
|
1768
1799
|
raise Exception("Failed to create stream: {}".format(error))
|
|
1769
1800
|
|
|
1770
1801
|
self._initialized = True
|
|
1802
|
+
record_owner_pid(self)
|
|
1771
1803
|
|
|
1772
1804
|
def __enter__(self):
|
|
1773
1805
|
"""Context manager entry."""
|
|
@@ -1786,6 +1818,8 @@ class Stream:
|
|
|
1786
1818
|
hasn't been explicitly closed.
|
|
1787
1819
|
"""
|
|
1788
1820
|
try:
|
|
1821
|
+
if is_foreign_process(self):
|
|
1822
|
+
return
|
|
1789
1823
|
# Only cleanup if not already closed and we have a valid stream
|
|
1790
1824
|
if hasattr(self, '_closed') and not self._closed:
|
|
1791
1825
|
stream = self._stream
|
|
@@ -1816,6 +1850,10 @@ class Stream:
|
|
|
1816
1850
|
"""
|
|
1817
1851
|
if self._closed:
|
|
1818
1852
|
return
|
|
1853
|
+
if is_foreign_process(self):
|
|
1854
|
+
self._closed = True
|
|
1855
|
+
self._initialized = False
|
|
1856
|
+
return
|
|
1819
1857
|
|
|
1820
1858
|
try:
|
|
1821
1859
|
# Clean up stream first as it depends on callbacks
|
|
@@ -2888,6 +2926,7 @@ class Builder(ManagedResource):
|
|
|
2888
2926
|
'url_error': "Error setting remote URL: {}",
|
|
2889
2927
|
'resource_error': "Error adding resource: {}",
|
|
2890
2928
|
'ingredient_error': "Error adding ingredient: {}",
|
|
2929
|
+
'archive_read_error': "Error loading ingredient from archive: {}",
|
|
2891
2930
|
'action_error': "Error adding action: {}",
|
|
2892
2931
|
'archive_error': "Error writing archive: {}",
|
|
2893
2932
|
'sign_error': "Error during signing: {}",
|
|
@@ -3277,6 +3316,55 @@ class Builder(ManagedResource):
|
|
|
3277
3316
|
Builder._ERROR_MESSAGES["archive_error"].format("Unknown error"),
|
|
3278
3317
|
check=lambda r: r != 0)
|
|
3279
3318
|
|
|
3319
|
+
def write_ingredient_archive(self, ingredient_id: str, stream: Any) -> None:
|
|
3320
|
+
"""Write a single-ingredient archive for the named ingredient.
|
|
3321
|
+
The archive is in C2PA format.
|
|
3322
|
+
|
|
3323
|
+
Args:
|
|
3324
|
+
ingredient_id: Identifier of the ingredient within this builder;
|
|
3325
|
+
matched against the ingredient's label or instance_id
|
|
3326
|
+
stream: Writable, seekable stream to receive the archive
|
|
3327
|
+
|
|
3328
|
+
Raises:
|
|
3329
|
+
C2paError: If there was an error writing the archive
|
|
3330
|
+
"""
|
|
3331
|
+
self._ensure_valid_state()
|
|
3332
|
+
|
|
3333
|
+
ingredient_id_str = _to_utf8_bytes(ingredient_id, "ingredient_id")
|
|
3334
|
+
|
|
3335
|
+
with Stream(stream) as stream_obj:
|
|
3336
|
+
result = _lib.c2pa_builder_write_ingredient_archive(
|
|
3337
|
+
self._handle, ingredient_id_str, stream_obj._stream)
|
|
3338
|
+
|
|
3339
|
+
_check_ffi_operation_result(result,
|
|
3340
|
+
Builder._ERROR_MESSAGES["archive_error"].format(
|
|
3341
|
+
"Unknown error"
|
|
3342
|
+
),
|
|
3343
|
+
check=lambda r: r != 0)
|
|
3344
|
+
|
|
3345
|
+
def add_ingredient_from_archive(self, stream: Any) -> None:
|
|
3346
|
+
"""Add an ingredient from a per-ingredient archive stream.
|
|
3347
|
+
The archive must be in C2PA format, as written by
|
|
3348
|
+
write_ingredient_archive.
|
|
3349
|
+
|
|
3350
|
+
Args:
|
|
3351
|
+
stream: Readable, seekable stream containing the ingredient archive
|
|
3352
|
+
|
|
3353
|
+
Raises:
|
|
3354
|
+
C2paError: If there was an error reading the archive
|
|
3355
|
+
"""
|
|
3356
|
+
self._ensure_valid_state()
|
|
3357
|
+
|
|
3358
|
+
with Stream(stream) as stream_obj:
|
|
3359
|
+
result = _lib.c2pa_builder_add_ingredient_from_archive(
|
|
3360
|
+
self._handle, stream_obj._stream)
|
|
3361
|
+
|
|
3362
|
+
_check_ffi_operation_result(result,
|
|
3363
|
+
Builder._ERROR_MESSAGES["archive_read_error"].format(
|
|
3364
|
+
"Unknown error"
|
|
3365
|
+
),
|
|
3366
|
+
check=lambda r: r != 0)
|
|
3367
|
+
|
|
3280
3368
|
def with_archive(self, stream: Any) -> 'Builder':
|
|
3281
3369
|
"""Load an archive into this Builder instance, replacing its
|
|
3282
3370
|
manifest definition. The archive carries only the
|
|
@@ -299,3 +299,28 @@ def dynamically_load_library(
|
|
|
299
299
|
raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
|
|
300
300
|
|
|
301
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,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: c2pa-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.1
|
|
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
|
-
Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
|
|
6
|
+
Maintainer-email: Gavin Peacock <gpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
|
|
7
7
|
License: MIT OR Apache-2.0
|
|
8
8
|
Project-URL: homepage, https://contentauthenticity.org
|
|
9
9
|
Project-URL: repository, https://github.com/contentauth/c2pa-python
|
|
@@ -23,7 +23,6 @@ from cryptography.hazmat.backends import default_backend
|
|
|
23
23
|
import tempfile
|
|
24
24
|
import shutil
|
|
25
25
|
import ctypes
|
|
26
|
-
import toml
|
|
27
26
|
import threading
|
|
28
27
|
|
|
29
28
|
# Suppress deprecation warnings
|
|
@@ -1945,6 +1944,500 @@ class TestBuilderWithSigner(unittest.TestCase):
|
|
|
1945
1944
|
self.assertIsNotNone(result.get('validation_state'))
|
|
1946
1945
|
self.assertEqual(result.get('validation_state'), "Trusted")
|
|
1947
1946
|
|
|
1947
|
+
def test_write_ingredient_archive_produces_readable_archive(self):
|
|
1948
|
+
manifest = {
|
|
1949
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
|
|
1950
|
+
"assertions": [],
|
|
1951
|
+
}
|
|
1952
|
+
builder = Builder.from_json(manifest)
|
|
1953
|
+
ingredient_json = {
|
|
1954
|
+
"title": "A.jpg",
|
|
1955
|
+
"relationship": "componentOf",
|
|
1956
|
+
"instance_id": "ingredient-A",
|
|
1957
|
+
}
|
|
1958
|
+
with open(self.testPath, "rb") as f:
|
|
1959
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
1960
|
+
archive = io.BytesIO()
|
|
1961
|
+
builder.write_ingredient_archive("ingredient-A", archive)
|
|
1962
|
+
builder.close()
|
|
1963
|
+
self.assertGreater(len(archive.getvalue()), 0)
|
|
1964
|
+
archive.close()
|
|
1965
|
+
|
|
1966
|
+
def test_add_ingredient_from_archive_roundtrip(self):
|
|
1967
|
+
manifest = {
|
|
1968
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
|
|
1969
|
+
"assertions": [],
|
|
1970
|
+
}
|
|
1971
|
+
builder = Builder.from_json(manifest)
|
|
1972
|
+
ingredient_json = {
|
|
1973
|
+
"title": "A.jpg",
|
|
1974
|
+
"relationship": "componentOf",
|
|
1975
|
+
"instance_id": "catalog:ingredient-A",
|
|
1976
|
+
}
|
|
1977
|
+
with open(self.testPath, "rb") as f:
|
|
1978
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
1979
|
+
archive = io.BytesIO()
|
|
1980
|
+
builder.write_ingredient_archive("catalog:ingredient-A", archive)
|
|
1981
|
+
builder.close()
|
|
1982
|
+
|
|
1983
|
+
archive.seek(0)
|
|
1984
|
+
builder2 = Builder.from_json(manifest)
|
|
1985
|
+
builder2.add_ingredient_from_archive(archive)
|
|
1986
|
+
archive.close()
|
|
1987
|
+
|
|
1988
|
+
output = io.BytesIO()
|
|
1989
|
+
with open(self.testPath, "rb") as f:
|
|
1990
|
+
builder2.sign(self.signer, "image/jpeg", f, output)
|
|
1991
|
+
builder2.close()
|
|
1992
|
+
|
|
1993
|
+
output.seek(0)
|
|
1994
|
+
reader = Reader("image/jpeg", output)
|
|
1995
|
+
json_data = reader.json()
|
|
1996
|
+
self.assertIn("A.jpg", json_data)
|
|
1997
|
+
output.close()
|
|
1998
|
+
|
|
1999
|
+
def test_add_ingredient_from_archive_preserves_instance_id(self):
|
|
2000
|
+
manifest = {
|
|
2001
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
|
|
2002
|
+
"assertions": [],
|
|
2003
|
+
}
|
|
2004
|
+
archive_builder = Builder.from_json(manifest)
|
|
2005
|
+
ingredient_json = {
|
|
2006
|
+
"title": "photo.jpg",
|
|
2007
|
+
"relationship": "parentOf",
|
|
2008
|
+
"instance_id": "my-ingredient",
|
|
2009
|
+
}
|
|
2010
|
+
with open(self.testPath, "rb") as f:
|
|
2011
|
+
archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2012
|
+
archive = io.BytesIO()
|
|
2013
|
+
archive_builder.write_ingredient_archive("my-ingredient", archive)
|
|
2014
|
+
archive_builder.close()
|
|
2015
|
+
|
|
2016
|
+
archive.seek(0)
|
|
2017
|
+
signing_builder = Builder.from_json(manifest)
|
|
2018
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2019
|
+
archive.close()
|
|
2020
|
+
|
|
2021
|
+
output = io.BytesIO()
|
|
2022
|
+
with open(self.testPath, "rb") as f:
|
|
2023
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2024
|
+
signing_builder.close()
|
|
2025
|
+
|
|
2026
|
+
output.seek(0)
|
|
2027
|
+
reader = Reader("image/jpeg", output)
|
|
2028
|
+
json_data = reader.json()
|
|
2029
|
+
self.assertIn("photo.jpg", json_data)
|
|
2030
|
+
output.close()
|
|
2031
|
+
|
|
2032
|
+
def test_add_ingredient_from_archive_preserves_instance_id_component_of(self):
|
|
2033
|
+
manifest = {
|
|
2034
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2035
|
+
"assertions": [],
|
|
2036
|
+
}
|
|
2037
|
+
archive_builder = Builder.from_json(manifest)
|
|
2038
|
+
ingredient_json = {
|
|
2039
|
+
"title": "photo.jpg",
|
|
2040
|
+
"relationship": "componentOf",
|
|
2041
|
+
"instance_id": "my-ingredient",
|
|
2042
|
+
}
|
|
2043
|
+
with open(self.testPath, "rb") as f:
|
|
2044
|
+
archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2045
|
+
archive = io.BytesIO()
|
|
2046
|
+
archive_builder.write_ingredient_archive("my-ingredient", archive)
|
|
2047
|
+
archive_builder.close()
|
|
2048
|
+
|
|
2049
|
+
archive.seek(0)
|
|
2050
|
+
signing_builder = Builder.from_json(manifest)
|
|
2051
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2052
|
+
archive.close()
|
|
2053
|
+
|
|
2054
|
+
output = io.BytesIO()
|
|
2055
|
+
with open(self.testPath, "rb") as f:
|
|
2056
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2057
|
+
signing_builder.close()
|
|
2058
|
+
|
|
2059
|
+
output.seek(0)
|
|
2060
|
+
reader = Reader("image/jpeg", output)
|
|
2061
|
+
json_data = reader.json()
|
|
2062
|
+
self.assertIn("photo.jpg", json_data)
|
|
2063
|
+
self.assertIn("componentOf", json_data)
|
|
2064
|
+
output.close()
|
|
2065
|
+
|
|
2066
|
+
def test_add_ingredient_from_archive_preserves_instance_id_input_to(self):
|
|
2067
|
+
manifest = {
|
|
2068
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2069
|
+
"assertions": [],
|
|
2070
|
+
}
|
|
2071
|
+
archive_builder = Builder.from_json(manifest)
|
|
2072
|
+
ingredient_json = {
|
|
2073
|
+
"title": "photo.jpg",
|
|
2074
|
+
"relationship": "inputTo",
|
|
2075
|
+
"instance_id": "my-ingredient",
|
|
2076
|
+
}
|
|
2077
|
+
with open(self.testPath, "rb") as f:
|
|
2078
|
+
archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2079
|
+
archive = io.BytesIO()
|
|
2080
|
+
archive_builder.write_ingredient_archive("my-ingredient", archive)
|
|
2081
|
+
archive_builder.close()
|
|
2082
|
+
|
|
2083
|
+
archive.seek(0)
|
|
2084
|
+
signing_builder = Builder.from_json(manifest)
|
|
2085
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2086
|
+
archive.close()
|
|
2087
|
+
|
|
2088
|
+
output = io.BytesIO()
|
|
2089
|
+
with open(self.testPath, "rb") as f:
|
|
2090
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2091
|
+
signing_builder.close()
|
|
2092
|
+
|
|
2093
|
+
output.seek(0)
|
|
2094
|
+
reader = Reader("image/jpeg", output)
|
|
2095
|
+
json_data = reader.json()
|
|
2096
|
+
self.assertIn("photo.jpg", json_data)
|
|
2097
|
+
self.assertIn("inputTo", json_data)
|
|
2098
|
+
output.close()
|
|
2099
|
+
|
|
2100
|
+
def test_add_ingredient_from_archive_roundtrip_parent_of(self):
|
|
2101
|
+
manifest = {
|
|
2102
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2103
|
+
"assertions": [],
|
|
2104
|
+
}
|
|
2105
|
+
builder = Builder.from_json(manifest)
|
|
2106
|
+
ingredient_json = {
|
|
2107
|
+
"title": "A.jpg",
|
|
2108
|
+
"relationship": "parentOf",
|
|
2109
|
+
"instance_id": "catalog:ingredient-A",
|
|
2110
|
+
}
|
|
2111
|
+
with open(self.testPath, "rb") as f:
|
|
2112
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2113
|
+
archive = io.BytesIO()
|
|
2114
|
+
builder.write_ingredient_archive("catalog:ingredient-A", archive)
|
|
2115
|
+
builder.close()
|
|
2116
|
+
|
|
2117
|
+
archive.seek(0)
|
|
2118
|
+
builder2 = Builder.from_json(manifest)
|
|
2119
|
+
builder2.add_ingredient_from_archive(archive)
|
|
2120
|
+
archive.close()
|
|
2121
|
+
|
|
2122
|
+
output = io.BytesIO()
|
|
2123
|
+
with open(self.testPath, "rb") as f:
|
|
2124
|
+
builder2.sign(self.signer, "image/jpeg", f, output)
|
|
2125
|
+
builder2.close()
|
|
2126
|
+
|
|
2127
|
+
output.seek(0)
|
|
2128
|
+
reader = Reader("image/jpeg", output)
|
|
2129
|
+
json_data = reader.json()
|
|
2130
|
+
self.assertIn("A.jpg", json_data)
|
|
2131
|
+
self.assertIn("parentOf", json_data)
|
|
2132
|
+
output.close()
|
|
2133
|
+
|
|
2134
|
+
def test_add_ingredient_from_archive_roundtrip_input_to(self):
|
|
2135
|
+
manifest = {
|
|
2136
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2137
|
+
"assertions": [],
|
|
2138
|
+
}
|
|
2139
|
+
builder = Builder.from_json(manifest)
|
|
2140
|
+
ingredient_json = {
|
|
2141
|
+
"title": "A.jpg",
|
|
2142
|
+
"relationship": "inputTo",
|
|
2143
|
+
"instance_id": "catalog:ingredient-A",
|
|
2144
|
+
}
|
|
2145
|
+
with open(self.testPath, "rb") as f:
|
|
2146
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2147
|
+
archive = io.BytesIO()
|
|
2148
|
+
builder.write_ingredient_archive("catalog:ingredient-A", archive)
|
|
2149
|
+
builder.close()
|
|
2150
|
+
|
|
2151
|
+
archive.seek(0)
|
|
2152
|
+
builder2 = Builder.from_json(manifest)
|
|
2153
|
+
builder2.add_ingredient_from_archive(archive)
|
|
2154
|
+
archive.close()
|
|
2155
|
+
|
|
2156
|
+
output = io.BytesIO()
|
|
2157
|
+
with open(self.testPath, "rb") as f:
|
|
2158
|
+
builder2.sign(self.signer, "image/jpeg", f, output)
|
|
2159
|
+
builder2.close()
|
|
2160
|
+
|
|
2161
|
+
output.seek(0)
|
|
2162
|
+
reader = Reader("image/jpeg", output)
|
|
2163
|
+
json_data = reader.json()
|
|
2164
|
+
self.assertIn("A.jpg", json_data)
|
|
2165
|
+
self.assertIn("inputTo", json_data)
|
|
2166
|
+
output.close()
|
|
2167
|
+
|
|
2168
|
+
def test_ingredient_from_archive_linked_to_opened_action(self):
|
|
2169
|
+
ingredient_id = "xmp:iid:archive-parent-001"
|
|
2170
|
+
manifest = {
|
|
2171
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2172
|
+
"assertions": [
|
|
2173
|
+
{
|
|
2174
|
+
"label": "c2pa.actions.v2",
|
|
2175
|
+
"data": {
|
|
2176
|
+
"actions": [
|
|
2177
|
+
{
|
|
2178
|
+
"action": "c2pa.opened",
|
|
2179
|
+
"parameters": {"ingredientIds": [ingredient_id]},
|
|
2180
|
+
}
|
|
2181
|
+
]
|
|
2182
|
+
},
|
|
2183
|
+
}
|
|
2184
|
+
],
|
|
2185
|
+
}
|
|
2186
|
+
archive_builder = Builder.from_json(manifest)
|
|
2187
|
+
with open(self.testPath, "rb") as f:
|
|
2188
|
+
archive_builder.add_ingredient(
|
|
2189
|
+
{"title": "photo.jpg", "relationship": "parentOf", "instance_id": ingredient_id},
|
|
2190
|
+
"image/jpeg", f)
|
|
2191
|
+
archive = io.BytesIO()
|
|
2192
|
+
archive_builder.write_ingredient_archive(ingredient_id, archive)
|
|
2193
|
+
archive_builder.close()
|
|
2194
|
+
|
|
2195
|
+
archive.seek(0)
|
|
2196
|
+
signing_builder = Builder.from_json(manifest)
|
|
2197
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2198
|
+
archive.close()
|
|
2199
|
+
|
|
2200
|
+
output = io.BytesIO()
|
|
2201
|
+
with open(self.testPath, "rb") as f:
|
|
2202
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2203
|
+
signing_builder.close()
|
|
2204
|
+
|
|
2205
|
+
output.seek(0)
|
|
2206
|
+
reader = Reader("image/jpeg", output)
|
|
2207
|
+
json_data = reader.json()
|
|
2208
|
+
self.assertIn("c2pa.opened", json_data)
|
|
2209
|
+
self.assertIn(ingredient_id, json_data)
|
|
2210
|
+
output.close()
|
|
2211
|
+
|
|
2212
|
+
def test_ingredient_from_archive_linked_to_placed_action(self):
|
|
2213
|
+
ingredient_id = "xmp:iid:archive-component-001"
|
|
2214
|
+
manifest = {
|
|
2215
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2216
|
+
"assertions": [
|
|
2217
|
+
{
|
|
2218
|
+
"label": "c2pa.actions.v2",
|
|
2219
|
+
"data": {
|
|
2220
|
+
"actions": [
|
|
2221
|
+
{
|
|
2222
|
+
"action": "c2pa.placed",
|
|
2223
|
+
"parameters": {"ingredientIds": [ingredient_id]},
|
|
2224
|
+
}
|
|
2225
|
+
]
|
|
2226
|
+
},
|
|
2227
|
+
}
|
|
2228
|
+
],
|
|
2229
|
+
}
|
|
2230
|
+
archive_builder = Builder.from_json(manifest)
|
|
2231
|
+
with open(self.testPath, "rb") as f:
|
|
2232
|
+
archive_builder.add_ingredient(
|
|
2233
|
+
{"title": "photo.jpg", "relationship": "componentOf", "instance_id": ingredient_id},
|
|
2234
|
+
"image/jpeg", f)
|
|
2235
|
+
archive = io.BytesIO()
|
|
2236
|
+
archive_builder.write_ingredient_archive(ingredient_id, archive)
|
|
2237
|
+
archive_builder.close()
|
|
2238
|
+
|
|
2239
|
+
archive.seek(0)
|
|
2240
|
+
signing_builder = Builder.from_json(manifest)
|
|
2241
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2242
|
+
archive.close()
|
|
2243
|
+
|
|
2244
|
+
output = io.BytesIO()
|
|
2245
|
+
with open(self.testPath, "rb") as f:
|
|
2246
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2247
|
+
signing_builder.close()
|
|
2248
|
+
|
|
2249
|
+
output.seek(0)
|
|
2250
|
+
reader = Reader("image/jpeg", output)
|
|
2251
|
+
json_data = reader.json()
|
|
2252
|
+
self.assertIn("c2pa.placed", json_data)
|
|
2253
|
+
self.assertIn(ingredient_id, json_data)
|
|
2254
|
+
output.close()
|
|
2255
|
+
|
|
2256
|
+
def test_ingredient_from_archive_linked_to_edited_action(self):
|
|
2257
|
+
ingredient_id = "xmp:iid:archive-input-001"
|
|
2258
|
+
manifest = {
|
|
2259
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2260
|
+
"assertions": [
|
|
2261
|
+
{
|
|
2262
|
+
"label": "c2pa.actions.v2",
|
|
2263
|
+
"data": {
|
|
2264
|
+
"actions": [
|
|
2265
|
+
{
|
|
2266
|
+
"action": "c2pa.edited",
|
|
2267
|
+
"parameters": {"ingredientIds": [ingredient_id]},
|
|
2268
|
+
}
|
|
2269
|
+
]
|
|
2270
|
+
},
|
|
2271
|
+
}
|
|
2272
|
+
],
|
|
2273
|
+
}
|
|
2274
|
+
archive_builder = Builder.from_json(manifest)
|
|
2275
|
+
with open(self.testPath, "rb") as f:
|
|
2276
|
+
archive_builder.add_ingredient(
|
|
2277
|
+
{"title": "photo.jpg", "relationship": "inputTo", "instance_id": ingredient_id},
|
|
2278
|
+
"image/jpeg", f)
|
|
2279
|
+
archive = io.BytesIO()
|
|
2280
|
+
archive_builder.write_ingredient_archive(ingredient_id, archive)
|
|
2281
|
+
archive_builder.close()
|
|
2282
|
+
|
|
2283
|
+
archive.seek(0)
|
|
2284
|
+
signing_builder = Builder.from_json(manifest)
|
|
2285
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2286
|
+
archive.close()
|
|
2287
|
+
|
|
2288
|
+
output = io.BytesIO()
|
|
2289
|
+
with open(self.testPath, "rb") as f:
|
|
2290
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2291
|
+
signing_builder.close()
|
|
2292
|
+
|
|
2293
|
+
output.seek(0)
|
|
2294
|
+
reader = Reader("image/jpeg", output)
|
|
2295
|
+
json_data = reader.json()
|
|
2296
|
+
self.assertIn("c2pa.edited", json_data)
|
|
2297
|
+
self.assertIn(ingredient_id, json_data)
|
|
2298
|
+
output.close()
|
|
2299
|
+
|
|
2300
|
+
def test_add_two_ingredient_archives_to_one_builder(self):
|
|
2301
|
+
manifest = {
|
|
2302
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2303
|
+
"assertions": [],
|
|
2304
|
+
}
|
|
2305
|
+
archives = []
|
|
2306
|
+
for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]:
|
|
2307
|
+
archive_builder = Builder.from_json(manifest)
|
|
2308
|
+
ingredient_json = {
|
|
2309
|
+
"title": title,
|
|
2310
|
+
"relationship": "componentOf",
|
|
2311
|
+
"instance_id": instance_id,
|
|
2312
|
+
}
|
|
2313
|
+
with open(self.testPath, "rb") as f:
|
|
2314
|
+
archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2315
|
+
archive = io.BytesIO()
|
|
2316
|
+
archive_builder.write_ingredient_archive(instance_id, archive)
|
|
2317
|
+
archive_builder.close()
|
|
2318
|
+
archive.seek(0)
|
|
2319
|
+
archives.append(archive)
|
|
2320
|
+
|
|
2321
|
+
signing_builder = Builder.from_json(manifest)
|
|
2322
|
+
for archive in archives:
|
|
2323
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2324
|
+
archive.close()
|
|
2325
|
+
|
|
2326
|
+
output = io.BytesIO()
|
|
2327
|
+
with open(self.testPath, "rb") as f:
|
|
2328
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2329
|
+
signing_builder.close()
|
|
2330
|
+
|
|
2331
|
+
output.seek(0)
|
|
2332
|
+
reader = Reader("image/jpeg", output)
|
|
2333
|
+
json_data = reader.json()
|
|
2334
|
+
self.assertIn("A.jpg", json_data)
|
|
2335
|
+
self.assertIn("B.jpg", json_data)
|
|
2336
|
+
output.close()
|
|
2337
|
+
|
|
2338
|
+
def test_write_ingredient_archive_only_contains_requested_ingredient(self):
|
|
2339
|
+
manifest = {
|
|
2340
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2341
|
+
"assertions": [],
|
|
2342
|
+
}
|
|
2343
|
+
archive_builder = Builder.from_json(manifest)
|
|
2344
|
+
for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]:
|
|
2345
|
+
ingredient_json = {
|
|
2346
|
+
"title": title,
|
|
2347
|
+
"relationship": "componentOf",
|
|
2348
|
+
"instance_id": instance_id,
|
|
2349
|
+
}
|
|
2350
|
+
with open(self.testPath, "rb") as f:
|
|
2351
|
+
archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2352
|
+
archive = io.BytesIO()
|
|
2353
|
+
archive_builder.write_ingredient_archive("ingredient-A", archive)
|
|
2354
|
+
archive_builder.close()
|
|
2355
|
+
|
|
2356
|
+
archive.seek(0)
|
|
2357
|
+
signing_builder = Builder.from_json(manifest)
|
|
2358
|
+
signing_builder.add_ingredient_from_archive(archive)
|
|
2359
|
+
archive.close()
|
|
2360
|
+
|
|
2361
|
+
output = io.BytesIO()
|
|
2362
|
+
with open(self.testPath, "rb") as f:
|
|
2363
|
+
signing_builder.sign(self.signer, "image/jpeg", f, output)
|
|
2364
|
+
signing_builder.close()
|
|
2365
|
+
|
|
2366
|
+
output.seek(0)
|
|
2367
|
+
reader = Reader("image/jpeg", output)
|
|
2368
|
+
json_data = reader.json()
|
|
2369
|
+
self.assertIn("A.jpg", json_data)
|
|
2370
|
+
self.assertNotIn("B.jpg", json_data)
|
|
2371
|
+
output.close()
|
|
2372
|
+
|
|
2373
|
+
def test_write_ingredient_archive_matches_label_or_instance_id(self):
|
|
2374
|
+
manifest = {
|
|
2375
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2376
|
+
"assertions": [],
|
|
2377
|
+
}
|
|
2378
|
+
ingredient_json = {
|
|
2379
|
+
"title": "photo.jpg",
|
|
2380
|
+
"relationship": "componentOf",
|
|
2381
|
+
"label": "my-label",
|
|
2382
|
+
"instance_id": "my-instance-id",
|
|
2383
|
+
}
|
|
2384
|
+
builder = Builder.from_json(manifest)
|
|
2385
|
+
with open(self.testPath, "rb") as f:
|
|
2386
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2387
|
+
|
|
2388
|
+
by_label = io.BytesIO()
|
|
2389
|
+
builder.write_ingredient_archive("my-label", by_label)
|
|
2390
|
+
self.assertGreater(len(by_label.getvalue()), 0)
|
|
2391
|
+
by_label.close()
|
|
2392
|
+
|
|
2393
|
+
by_instance_id = io.BytesIO()
|
|
2394
|
+
builder.write_ingredient_archive("my-instance-id", by_instance_id)
|
|
2395
|
+
self.assertGreater(len(by_instance_id.getvalue()), 0)
|
|
2396
|
+
by_instance_id.close()
|
|
2397
|
+
|
|
2398
|
+
builder.close()
|
|
2399
|
+
|
|
2400
|
+
def test_write_ingredient_archive_unknown_id_raises(self):
|
|
2401
|
+
manifest = {
|
|
2402
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2403
|
+
"assertions": [],
|
|
2404
|
+
}
|
|
2405
|
+
builder = Builder.from_json(manifest)
|
|
2406
|
+
ingredient_json = {
|
|
2407
|
+
"title": "photo.jpg",
|
|
2408
|
+
"relationship": "componentOf",
|
|
2409
|
+
"instance_id": "ingredient-A",
|
|
2410
|
+
}
|
|
2411
|
+
with open(self.testPath, "rb") as f:
|
|
2412
|
+
builder.add_ingredient(ingredient_json, "image/jpeg", f)
|
|
2413
|
+
with self.assertRaises(Error):
|
|
2414
|
+
builder.write_ingredient_archive("nonexistent-id", io.BytesIO())
|
|
2415
|
+
builder.close()
|
|
2416
|
+
|
|
2417
|
+
def test_add_ingredient_from_archive_invalid_data_raises(self):
|
|
2418
|
+
manifest = {
|
|
2419
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2420
|
+
"assertions": [],
|
|
2421
|
+
}
|
|
2422
|
+
builder = Builder.from_json(manifest)
|
|
2423
|
+
with self.assertRaises(Error):
|
|
2424
|
+
builder.add_ingredient_from_archive(io.BytesIO(b"not a c2pa archive"))
|
|
2425
|
+
with self.assertRaises(Error):
|
|
2426
|
+
builder.add_ingredient_from_archive(io.BytesIO())
|
|
2427
|
+
builder.close()
|
|
2428
|
+
|
|
2429
|
+
def test_ingredient_archive_methods_raise_on_closed_builder(self):
|
|
2430
|
+
manifest = {
|
|
2431
|
+
"claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
|
|
2432
|
+
"assertions": [],
|
|
2433
|
+
}
|
|
2434
|
+
builder = Builder.from_json(manifest)
|
|
2435
|
+
builder.close()
|
|
2436
|
+
with self.assertRaises(Error):
|
|
2437
|
+
builder.write_ingredient_archive("ingredient-A", io.BytesIO())
|
|
2438
|
+
with self.assertRaises(Error):
|
|
2439
|
+
builder.add_ingredient_from_archive(io.BytesIO())
|
|
2440
|
+
|
|
1948
2441
|
def test_remote_sign(self):
|
|
1949
2442
|
with open(self.testPath, "rb") as file:
|
|
1950
2443
|
builder = Builder(self.manifestDefinition)
|
|
@@ -6255,5 +6748,68 @@ class TestContextIntegration(TestContextAPIs):
|
|
|
6255
6748
|
context.close()
|
|
6256
6749
|
|
|
6257
6750
|
|
|
6751
|
+
class TestStreamReferences(unittest.TestCase):
|
|
6752
|
+
|
|
6753
|
+
def test_stream_collected_after_del(self):
|
|
6754
|
+
"""Stream must be collected by reference counting."""
|
|
6755
|
+
import gc
|
|
6756
|
+
import weakref
|
|
6757
|
+
gc.collect()
|
|
6758
|
+
garbage_before = len(gc.garbage)
|
|
6759
|
+
|
|
6760
|
+
buf = io.BytesIO(b"hello world")
|
|
6761
|
+
s = Stream(buf)
|
|
6762
|
+
ref = weakref.ref(s)
|
|
6763
|
+
del s
|
|
6764
|
+
|
|
6765
|
+
# Trigger gc, we want to verify it's collected
|
|
6766
|
+
# collected now (1 gc call) means no gc cycle breaker needed
|
|
6767
|
+
# aka ref cycle did not happen.
|
|
6768
|
+
gc.collect()
|
|
6769
|
+
|
|
6770
|
+
self.assertIsNone(ref(), "Stream not collected")
|
|
6771
|
+
self.assertEqual(len(gc.garbage), garbage_before,
|
|
6772
|
+
"Stream added objects to gc.garbage")
|
|
6773
|
+
|
|
6774
|
+
def test_stream_not_added_to_gc_garbage_list(self):
|
|
6775
|
+
"""Creating and dropping many Streams must not grow gc.garbage."""
|
|
6776
|
+
import gc
|
|
6777
|
+
gc.collect()
|
|
6778
|
+
gc.garbage.clear()
|
|
6779
|
+
|
|
6780
|
+
for _ in range(20):
|
|
6781
|
+
s = Stream(io.BytesIO(b"data"))
|
|
6782
|
+
del s
|
|
6783
|
+
|
|
6784
|
+
gc.collect()
|
|
6785
|
+
self.assertEqual(len(gc.garbage), 0,
|
|
6786
|
+
f"gc.garbage unexpectedly non-empty: {gc.garbage}")
|
|
6787
|
+
|
|
6788
|
+
def test_callbacks_return_minus_one_after_stream_collected(self):
|
|
6789
|
+
"""Callbacks must return -1 gracefully when the Stream has been GC'd."""
|
|
6790
|
+
import gc
|
|
6791
|
+
import weakref
|
|
6792
|
+
|
|
6793
|
+
buf = io.BytesIO(b"test")
|
|
6794
|
+
s = Stream(buf)
|
|
6795
|
+
|
|
6796
|
+
read_cb = s._read_cb
|
|
6797
|
+
seek_cb = s._seek_cb
|
|
6798
|
+
write_cb = s._write_cb
|
|
6799
|
+
flush_cb = s._flush_cb
|
|
6800
|
+
ref = weakref.ref(s)
|
|
6801
|
+
del s
|
|
6802
|
+
gc.collect()
|
|
6803
|
+
|
|
6804
|
+
# Stream should be gone.
|
|
6805
|
+
self.assertIsNone(ref(), "Stream not collected before callback test")
|
|
6806
|
+
|
|
6807
|
+
# All callbacks must return -1 without crashing.
|
|
6808
|
+
self.assertEqual(read_cb(None, None, 0), -1)
|
|
6809
|
+
self.assertEqual(seek_cb(None, 0, 0), -1)
|
|
6810
|
+
self.assertEqual(write_cb(None, None, 0), -1)
|
|
6811
|
+
self.assertEqual(flush_cb(None), -1)
|
|
6812
|
+
|
|
6813
|
+
|
|
6258
6814
|
if __name__ == '__main__':
|
|
6259
6815
|
unittest.main(warnings='ignore')
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|