diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 52a10981..4694fb90 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -495,7 +495,7 @@ The cleanup order matters: `_release()` runs first (closing streams, dropping ca ### Dropping a Context reference -`Reader` and `Builder` both keep a `_context` attribute that is written once and never read. It is not dead code: it is what keeps the Context alive while the native handle depends on it. Without that reference, `Reader("image/jpeg", stream, context=Context())` would let the Context become collectable as soon as the constructor returned, even though the reader is still using it. +`Reader` and `Builder` both keep a `_context` attribute that is written once and never read. The reference keeps the Context alive while the native handle depends on it. Without it, `Reader("image/jpeg", stream, context=Context())` would let the Context become collectable as soon as the constructor returned, even though the reader is still using it. Clearing it in `_release()` is the other half of that. A closed Reader has no further use for the Context, and holding the reference would keep alive an object nothing can reach through the Reader's public API. @@ -530,7 +530,7 @@ sequenceDiagram Both `_cleanup_resources()` and the consumed teardown take this branch. Neither simply skips the work: they null the handle and mark the object `CLOSED` so the child cannot go on to use it or try to free it later. Mutating the child's copy has no effect on the parent's, which is untouched and still valid. -The memory the child skips is not lost for good. A child that calls `exec()` replaces its address space; a child that exits has its memory reclaimed by the OS. Even a long-lived child (a `multiprocessing` worker using the fork start method) retains at most the objects it inherited at fork time, which is a bounded, one-off amount rather than a growing leak. Anything the child allocates itself carries the child's own PID and is freed normally. +The memory the child skips is reclaimed anyway. A child that calls `exec()` replaces its address space; a child that exits has its memory reclaimed by the OS. Even a long-lived child (a `multiprocessing` worker using the fork start method) retains at most the objects it inherited at fork time, which is a bounded, one-off amount rather than a growing leak. Anything the child allocates itself carries the child's own PID and is freed normally. > [!NOTE] > `is_foreign_process()` returns `False` when no owner PID was ever recorded, so an object that somehow missed the stamp is cleaned up as before rather than leaking silently. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index efdd0b79..34587991 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -93,6 +93,10 @@ 'c2pa_context_builder_build', 'c2pa_context_builder_set_signer', 'c2pa_context_new', + # HTTP resolver bindings + 'c2pa_http_resolver_create', + 'c2pa_context_builder_set_http_resolver', + 'c2pa_error_set_last', # Free bindings 'c2pa_string_free', 'c2pa_free_string_array', @@ -288,6 +292,32 @@ def _free_native_ptr(ptr): result) return result + _native_malloc = None + + @staticmethod + def _get_native_malloc(): + """ + Return malloc from the C runtime whose free() is used. + + Some allocations must come from the matching runtime + or the free is heap corruption, so we retrieve that here. + Looked up lazily as needed, if needed, + """ + if ManagedResource._native_malloc is None: + if sys.platform == "win32": + try: + crt = ctypes.CDLL("ucrtbase") + except OSError: + crt = ctypes.CDLL("msvcrt") + else: + crt = ctypes.CDLL(None) + malloc = crt.malloc + malloc.argtypes = [ctypes.c_size_t] + # The default c_int restype truncates 64-bit pointers. + malloc.restype = ctypes.c_void_p + ManagedResource._native_malloc = malloc + return ManagedResource._native_malloc + def _ensure_valid_state(self): """Raise if the resource is closed or uninitialized.""" name = type(self).__name__ @@ -805,6 +835,56 @@ class C2paContext(ctypes.Structure): """Opaque structure for context.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paHttpRequest(ctypes.Structure): + """Internal ctypes for the native C2paHttpRequest struct. + + Read-only view: + Every pointer borrows native memory that is only valid + for the duration of the resolver callback. + Copy anything you want to keep for later on. + + The string fields are c_char_p so ctypes converts them + to bytes on read. + `body` stays a c_ubyte pointer because it is binary, + not NUL-terminated. + """ + _fields_ = [ + ("url", ctypes.c_char_p), + ("method", ctypes.c_char_p), + ("headers", ctypes.c_char_p), + ("body", ctypes.POINTER(ctypes.c_ubyte)), + ("body_len", ctypes.c_size_t), + ] + + +class C2paHttpResponse(ctypes.Structure): + """Internal ctypes mirror of the native C2paHttpResponse struct. + + The HTTP resolver callback fills this data in. + `body` must be allocated with the C runtime malloc + that the native library's free() matches: the native + side takes ownership and frees it on both the success and + the error return path. + """ + _fields_ = [ + ("status", ctypes.c_int), + ("body", ctypes.POINTER(ctypes.c_ubyte)), + ("body_len", ctypes.c_size_t), + ] + + +class C2paHttpResolver(ctypes.Structure): + """Opaque structure for a native HTTP resolver handle.""" + _fields_ = [] # Empty as it's opaque in the C API + + +HttpResolverCallback = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.POINTER(C2paHttpRequest), + ctypes.POINTER(C2paHttpResponse)) + # Helper function to set function prototypes @@ -1041,6 +1121,24 @@ def _setup_function(func, argtypes, restype=None): [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSigner)], ctypes.c_int ) + +# HTTP resolver bindings +_setup_function( + _lib.c2pa_http_resolver_create, + [ctypes.c_void_p, HttpResolverCallback], + ctypes.POINTER(C2paHttpResolver) +) +_setup_function( + _lib.c2pa_context_builder_set_http_resolver, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paHttpResolver)], + ctypes.c_int +) + +_setup_function( + _lib.c2pa_error_set_last, + [ctypes.c_char_p], + ctypes.c_int +) _setup_function( _lib.c2pa_builder_sign_context, [ctypes.POINTER(C2paBuilder), @@ -1464,6 +1562,179 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: check=lambda r: r != 0) +class C2paHttpRequestData: + """Decoded copy of a native HTTP request, + handed to a custom HTTP resolver + (configured using with_resolver on a Context). + + Attributes: + url: Absolute request URL. + method: HTTP method ("GET", "POST", ...). + headers: Request headers as a dict. + Names are lowercased by the native layer. + When a header repeats, the last value wins. + body: Request body bytes (b"" when there is none). + + All data is copied out of native memory, so it stays valid after the + resolver call returns. + """ + __slots__ = ("url", "method", "headers", "body") + + def __init__(self, url: str, method: str, headers: dict, body: bytes): + self.url = url + self.method = method + self.headers = headers + self.body = body + + def __repr__(self): + return (f"C2paHttpRequestData(method={self.method!r}, " + f"url={self.url!r}, body_len={len(self.body)})") + + +class C2paHttpResolverBridge: + """Plumbing that turns a Python resolver into the native trampoline + the C API calls to handle HTTP resolvers configured on a Context + using `with_resolver`. + """ + + @staticmethod + def _parse_header_lines(raw: str) -> dict: + """Parse the FFI's newline-delimited 'Name: Value' header block. + + The native side always sends a string (empty when there are no + headers), never NULL. + Header names arrive lowercased, and repeated headers are sent + as separate lines, so the last occurrence of a name wins here. + """ + headers = {} + for line in raw.split("\n"): + name, sep, value = line.partition(":") + if sep: + headers[name.strip()] = value.strip() + return headers + + @staticmethod + def _coerce_resolver(resolver): + """Normalize a HTTP resolver into a callable taking an HttpRequest. + + Accepts either an object with a resolve(request) method or a bare + callable with the same signature. + The request handed to it exposes .url/.method/.headers/.body, + and the value it returns only needs .status (int) and .body (bytes).. + """ + resolve_fn = getattr(resolver, "resolve", None) + if callable(resolve_fn): + return resolve_fn + if callable(resolver): + return resolver + raise TypeError( + "HTTP resolver must be callable or provide a resolve() method, " + f"got {type(resolver).__name__}") + + @staticmethod + def _make_trampoline(resolve_fn): + """Wrap a Python HTTP resolver function into a native C callback. + + Args: + resolve_fn: Callable taking a C2paHttpRequestData and + returning a duck-typed response (.status/.body). + + Returns: + The HttpResolverCallback object. + The caller MUST keep a reference to it for as long as + any native context built from it can run: + the native side holds only a raw function pointer, + and letting the thunk be collected while a context + can still call it is undefined behavior. + """ + def _trampoline(_ctx, request_ptr, response_ptr): + try: + req = request_ptr.contents + # Copy everything out now to keep it. + url = req.url.decode("utf-8", "replace") if req.url else "" + method = (req.method.decode("utf-8", "replace") + if req.method else "") + raw_headers = (req.headers.decode("utf-8", "replace") + if req.headers else "") + body = (ctypes.string_at(req.body, req.body_len) + if (req.body and req.body_len) else b"") + + result = resolve_fn(C2paHttpRequestData( + url=url, + method=method, + headers=C2paHttpResolverBridge._parse_header_lines( + raw_headers), + body=body)) + + payload = result.body + if payload is None: + payload = b"" + if not isinstance(payload, (bytes, bytearray)): + raise TypeError( + "resolver response .body must be bytes, got " + f"{type(payload).__name__}") + data = bytes(payload) + + status = getattr(result, "status", None) + if not isinstance(status, int) or isinstance(status, bool): + raise TypeError( + "resolver response .status must be an int, got " + f"{type(status).__name__}") + if not 100 <= status <= 599: + raise TypeError( + "resolver response .status out of range: " + f"{status}") + + # No fallible statement between the first write + # into `response` and `return 0`. + # `status` is written last, after the body field. + response = response_ptr.contents + if data: + length = len(data) + buf = ManagedResource._get_native_malloc()(length) + if not buf: + _lib.c2pa_error_set_last( + b"Other: HTTP resolver out of memory") + return -1 + ctypes.memmove(buf, data, length) + # Ownership handoff: from here the native library frees + # this buffer on return paths. Never free it here. + response.body = ctypes.cast( + buf, ctypes.POINTER(ctypes.c_ubyte)) + response.body_len = length + else: + # body and body_len must stay NULL/0 together: + # the native side skips its free when body_len is 0. + response.body = None + response.body_len = 0 + response.status = status + return 0 + except BaseException as e: # noqa: B036 - must not unwind native + # An exception escaping a ctypes callback cannot propagate + # into native lib, so it would be reported as a generic failure + # with the real cause lost. + # Catching it here turns it into a typed error carrying + # the actual message. + # + # Setting the error is mandatory: the native error slot is + # thread-local and is not cleared before the callback runs, + # so returning non-zero without setting it could otherwise + # surface a stale error. + try: + # Returned message size limited here + text = str(e).replace("\x00", "\\x00") + if len(text) > 1024: + text = text[:1024] + "...(truncated)" + _lib.c2pa_error_set_last( + f"Other: HTTP resolver trampoline failure: {text}" + .encode("utf-8", "replace")) + except BaseException: + pass + return -1 + + return HttpResolverCallback(_trampoline) + + class ContextProvider(ABC): """Abstract base class for types that provide a C2PA context. @@ -1603,6 +1874,8 @@ class ContextBuilder: def __init__(self): self._settings = None self._signer = None + self._resolver = None + self._resolve_fn = None def with_settings( self, settings: 'Settings', @@ -1630,11 +1903,56 @@ def with_signer( self._signer = signer return self + def with_resolver( + self, resolver, + ) -> 'ContextBuilder': + """Attach a custom HTTP resolver to the Context being built. + + The resolver handles every HTTP request the SDK makes through this + Context. Reader and Builder instances created without a Context + keep using the built-in resolver. + + Args: + resolver: A callable, or an object with a resolve(request) + method, matching the duck-typed contract above. Raising + from the resolver marks the request as a hard failure, + which surfaces as a typed C2paError. + + Returns: + self, for method chaining. + + Raises: + TypeError: resolver is neither callable + nor has a resolve() method. + + Notes: + - Only status 200 is accepted for a remote manifest fetch, any + other status surfaces as a typed C2paError. + - Security: a custom resolver bypasses the + core.allowed_network_hosts setting, which only filters the + built-in resolver. Host filtering becomes the resolver's + responsibility. + - Settings still gate the resolver. With + verify.remote_manifest_fetch set to false the resolver is + never invoked and the read fails instead. + - Do not call any other c2pa APIs from inside the resolver: + reentering the FFI while a call is in flight is undefined behavior. + - The resolver is retained for the Context's lifetime; avoid + having it capture the Context, Reader, or Builder (that forms + a reference cycle that delays native cleanup). + """ + self._resolve_fn = C2paHttpResolverBridge._coerce_resolver(resolver) + self._resolver = resolver + return self + def build(self) -> 'Context': """Build and return a configured Context.""" return Context( settings=self._settings, signer=self._signer, + resolver=self._resolver, + # Already coerced (and validated) by with_resolver. + _resolve_fn=self._resolve_fn, ) @@ -1663,10 +1981,23 @@ def __init__(self): _lib.c2pa_context_builder_new, "Failed to create ContextBuilder") + class _NativeHttpResolver(ManagedResource): + """Short-lived wrapper so the potential custom native HTTP + resolver handle (if set) follows the normal lifecycle. + """ + + def __init__(self, callback_cb): + super().__init__() + self._create_and_activate( + lambda: _lib.c2pa_http_resolver_create(None, callback_cb), + "Failed to create HTTP resolver") + def __init__( self, settings: Optional['Settings'] = None, signer: Optional['Signer'] = None, + resolver=None, + _resolve_fn=None, ): """Create a Context. @@ -1675,14 +2006,23 @@ def __init__( If None, default SDK settings are used. signer: Optional Signer. If provided it is consumed and must not be used directly again after that call. + resolver: Optional custom HTTP resolver, either an object with a + resolve(request) method or a callable with the same + signature. See ContextBuilder.with_resolver for the full + contract. + _resolve_fn: Private. The already-coerced callable for + `resolver`, passed by ContextBuilder.build() so the + coercion done by with_resolver is not repeated here. Raises: C2paError: If creation fails + TypeError: resolver is neither callable nor has a resolve() + method. """ super().__init__() self._init_attrs() - if settings is None and signer is None: + if settings is None and signer is None and resolver is None: # Simple default context self._create_and_activate( _lib.c2pa_context_new, @@ -1698,6 +2038,26 @@ def __init__( "Failed to set settings on Context", check=lambda r: r != 0) + if resolver is not None: + # Reuse the builder's coercion when it did one. + # Coerce here for the direct Context(resolver=...), + # from_json and from_dict paths. + resolve_fn = _resolve_fn + if resolve_fn is None: + resolve_fn = ( + C2paHttpResolverBridge._coerce_resolver(resolver)) + callback_cb = C2paHttpResolverBridge._make_trampoline( + resolve_fn) + # Pin the thunk before any native call can capture its + # raw function pointer (see _release for why it stays). + self._http_resolver_cb = callback_cb + with self._NativeHttpResolver(callback_cb) as native_res: + native_res._consume_no_replacement( + lambda h: + _lib.c2pa_context_builder_set_http_resolver( + nb._handle, h), + "Failed to set HTTP resolver on Context: {}") + if signer is not None: signer._ensure_valid_state() # A rejected signer is retained, not closed and leaked. @@ -1718,6 +2078,7 @@ def _init_attrs(self): super()._init_attrs() self._has_signer = False self._signer_callback_cb = None + self._http_resolver_cb = None def _release(self): """Release Context-specific resources.""" @@ -1733,19 +2094,22 @@ def from_json( cls, json_str: str, signer: Optional['Signer'] = None, + resolver=None, ) -> 'Context': """Create Context from a JSON configuration string. Args: json_str: JSON string with settings config. signer: Optional Signer (consumed if provided). + resolver: Optional custom HTTP resolver. See + ContextBuilder.with_resolver for the contract. Returns: A new Context instance. """ settings = Settings.from_json(json_str) try: - return cls(settings=settings, signer=signer) + return cls(settings=settings, signer=signer, resolver=resolver) finally: settings.close() @@ -1754,17 +2118,21 @@ def from_dict( cls, config: dict, signer: Optional['Signer'] = None, + resolver=None, ) -> 'Context': """Create Context from a dictionary. Args: config: Dictionary with settings configuration. signer: Optional Signer (consumed if provided). + resolver: Optional custom HTTP resolver. See + ContextBuilder.with_resolver for the contract. Returns: A new Context instance. """ - return cls.from_json(json.dumps(config), signer=signer) + return cls.from_json( + json.dumps(config), signer=signer, resolver=resolver) @property def has_signer(self) -> bool: @@ -3113,7 +3481,8 @@ def wrapped_callback( return -1 # Copy the signature back to the C buffer - # (since callback is used in native code) + # (since callback is used in native code). + signature = bytes(signature) actual_len = min(len(signature), signed_len) # Use memmove for efficient memory copying instead of # byte-by-byte loop diff --git a/tests/http_resolver/README.md b/tests/http_resolver/README.md new file mode 100644 index 00000000..2b1bb85d --- /dev/null +++ b/tests/http_resolver/README.md @@ -0,0 +1,239 @@ +# Custom HTTP resolver examples + +This document explains how (custom) HTTP resolvers work end to end, what the SDK does and does not do with HTTP, and the platform differences (Linux, Windows, macOS) that affect a resolver in practice. + +## Overview + +A custom HTTP resolver intercepts every HTTP request the SDK makes through a `Context`, so an application can add headers, cache responses, or serve responses from memory in tests. + +c2pa ships no resolver types at all: there is no `HttpRequest`, `HttpResponse`, or `HttpResolver` to import from `c2pa`. `ContextBuilder.with_resolver()` (and the `Context(resolver=...)` constructor argument, also accepted by `Context.from_json`/`from_dict`) never does an `isinstance` check. It accepts either: + +- any callable taking one request argument +- any object with a `resolve(request)` method + +The request passed to the custom resolver exposes `.url` (str), `.method` (str), `.headers` (dict), and `.body` (bytes). The value it returns only needs `.status` (int) and `.body` (bytes) attributes. The minimal form needs no imports at all: + +```py +def my_resolver(request): + # request.url, request.method, request.headers, request.body + return SomeResponse(status=200, body=b"...") + +context = c2pa.Context.builder().with_resolver(my_resolver).build() +reader = c2pa.Reader("image/jpeg", stream, context=context) +``` + +The resolver is validated immediately: something with a wrong shape raises `TypeError` from `with_resolver()` itself (or from the `Context` constructor on the direct path), not later at `.build()`. If a resolver object has both a `resolve()` method and is itself callable, `resolve()` wins. + +[`http_resolver_example_impl.py`](./http_resolver_example_impl.py) is an example implementation of that shape. It defines `HttpRequest`, `HttpResponse`, and an optional `HttpResolver` abstract base class (subclassing it is not required, as it just provides a documented, type-checkable contract instead of duck typing), and three example resolvers: + +- `DebugHttpResolver`: logs every request/response, delegates the transfer to `urllib`. +- `CachingHttpResolver`: TTL'd LRU cache plus retry/backoff for throttled requests. +- `AlwaysFailResolver`: answers every request with a fixed status and needs no network. + +The module has no dependency on `c2pa` itself. Only the tests import `c2pa`, to exercise the resolvers against real `Context`/`Reader`/`Builder` instances. + +## How the SDK uses the network + +The SDK asks the resolver whenever verifying or signing needs a remote resource. + +What that means in practice changes with the SDK version and configuration — a remote manifest fetch, a certificate revocation check, a trusted-timestamp request, and an identity-document lookup are all current examples, not a closed list. All of it is triggered by reading or signing an asset, and all of it funnels through the one resolver attached to the `Context`: + +```mermaid +flowchart LR + RB[Reader / Builder] --> C[Context] + C --> RES[Custom resolver] + RES --> NET[Whatever HTTP resource the SDK currently needs] +``` + +### Why http or why https + +The SDK does not pick the scheme. It uses whatever the URL carries (HTTP or HTTPS). That URL comes from outside the SDK: a remote reference, a certificate extension, a configured authority, or similar. The scheme is a property of the endpoint, not a setting the application controls. + +Most of what flows through is normally `https`. A certificate revocation check is a common example of one that can be plain `http` instead: its response is itself CMS-signed, so its integrity does not depend on TLS, and fetching it over `https` risks a circular dependency (validating a certificate would require validating the responder's own certificate first). An `http` URL sitting next to `https` everywhere else is normal, not a downgrade. + +Because the URL can be tweaked (it can come from whatever asset a caller supplies), a custom resolver should reject schemes it does not expect before making any request. The two network-facing examples, `DebugHttpResolver` and `CachingHttpResolver`, reject any URL whose scheme is not `http`/`https` for this reason (`AlwaysFailResolver` makes no request, so it does not). See the host-filtering note under [What the SDK leaves to the resolver](#what-the-sdk-leaves-to-the-resolver). + +## Notes on runtime + +The native library and the Python process must share a C runtime. The bridge allocates buffers that the native side frees with `libc::free`. A native library built against a different C runtime than the one the Python process loads (for example a static-musl `.so`) will corrupt memory. + +## What traffic flows through a resolver (and when) + +A resolver attached to a `Context` handles **every** HTTP request the SDK makes through that `Context`. Scope and gating to keep in mind: + +- The resolver is **per-Context**. `Reader` and `Builder` instances created *without* that `Context` keep using the SDK's built-in resolver. Attaching a resolver to one `Context` changes nothing anywhere else. +- `with_resolver()` can be called multiple times when creating a context to use: the last resolver set wins and will be used. +- **Settings still gate the resolver.** With `verify.remote_manifest_fetch` set to `false`, the resolver is never invoked for a remote-manifest read. The read fails instead. A resolver is not a way to re-enable disabled fetching. + +## How resolution works end to end + +The pipeline is: native code decides it needs an HTTP resource, calls back into Python, the custom resolver performs the transfer however it likes, then the response is copied back into native memory. + +```mermaid +flowchart LR + subgraph N[Native side] + direction TB + SDK[SDK needs an HTTP resource] + DONE[Response in native memory] + end + subgraph P[Python side] + RES["Custom resolver: resolve()"] + end + SDK -->|1. request| TR(["trampoline
(ctypes callback)"]) + TR -->|2. decoded request| RES + RES -->|3. status + body bytes| TR + TR -->|4. encoded response| DONE +``` + +The "trampoline" is the small ctypes callback that bridges the two sides. It is a function (built by `C2paHttpResolverBridge._make_trampoline`) that native code invokes through a raw C function pointer, and whose only job is to bounce that call into Python: decode the native request, call the custom resolver's `resolve()`, and encode the result back into native memory. It is a thin shim that exists only to redirect a call from one calling convention (here, the C ABI) into another (a Python callable). The call bounces off it into Python and the result bounces back across the C FFI boundary. It adapts between the two sides but does none of the HTTP work itself. + +Step by step: + +1. **Wiring.** `Context.__init__` normalizes the custom resolver into a callable (the `resolve` bound method, or the callable itself) and wraps it in a ctypes trampoline (`C2paHttpResolverBridge._make_trampoline`). A short-lived native resolver handle is created via `c2pa_http_resolver_create` and consumed by `c2pa_context_builder_set_http_resolver`. The native context builder takes ownership, so there is nothing for the caller to free. +2. **Invocation.** Whenever the native library needs an HTTP resource through that context, it synchronously invokes the trampoline with a pointer to a native request struct and a pointer to a zero-initialized native response struct. The call blocks the SDK operation (`Reader(...)`, `builder.sign(...)`, `add_ingredient(...)`) that triggered it, and **there is no SDK-side timeout**. A resolver that hangs, hangs that SDK call. Timeouts are entirely the custom resolver's responsibility (the network-facing examples pass `timeout=` to `urllib.request.urlopen`). +3. **Decode.** The trampoline copies everything out of the native request struct (URL, method, headers, body) into a plain Python object (`C2paHttpRequestData`) holding `str`/`bytes`. Because it is a copy, the request object stays valid after `resolve()` returns. Storing it (as `DebugHttpResolver` stores `(method, url)` tuples) is safe. +4. **Resolve.** The custom resolver's `resolve()` runs and returns a response-shaped object, or raises. +5. **Encode.** The trampoline validates the response (`.status` must be an `int`, `.body` must be `bytes`/`bytearray`), copies a non-empty body into a buffer allocated with the C runtime `malloc`, and writes status/body/length into the native response struct. Ownership of that buffer transfers to native code, which frees it on both the success and the error path. The custom resolver never allocates or frees anything. It only ever returns `bytes`. +6. **Consume.** The native side copies the body out, frees the buffer, and hands the response to whatever logic asked for it. + +```mermaid +sequenceDiagram + participant N as SDK native library + participant T as ctypes trampoline + participant R as Custom resolve() + N->>T: request*, response* (zero-init) + T->>T: copy url/method/headers/body to Python + T->>R: resolve(request) + R-->>T: response (.status int, .body bytes) + T->>T: validate, then malloc + copy body + T-->>N: write status/body/len, return 0 + N->>N: copy body out, then free() the buffer +``` + +## Request semantics + +- `url` is the absolute request URL. +- `method` is the HTTP verb (`GET`, `POST`, ...); which verb shows up depends on what's being fetched. +- `headers` is a dict. Header names arrive **lowercased** by the native layer. Repeated headers are delivered as separate lines internally. In the dict, the **last occurrence wins**. +- `body` is `b""` when there is no body. A request with a payload (typically a `POST`) carries one. `request.body or None` is the idiomatic way to hand it to `urllib.request.Request`, as the network-facing examples do. + +## Response and error semantics + +- **Status passes through.** Return the real status, and do not translate. Only `200` is treated as success; anything else surfaces to the SDK caller as a typed `C2paError`. `DebugHttpResolver` shows the right pattern for `urllib`: an `HTTPError` is still a response, so it returns `HttpResponse(e.code, e.read())` and lets the SDK produce its own error. +- **Raising marks a hard failure.** A transport-level problem (DNS failure, connection refused, timeout) is not a response. Raise, and the SDK reports the request as failed. The examples deliberately do *not* catch `urllib.error.URLError` for exactly this reason. +- **A raised exception does not propagate as itself.** Exceptions cannot unwind across the ctypes/native boundary. The trampoline catches everything the custom resolver raises, including `BaseException` and `KeyboardInterrupt`, records its message in the native error slot, and the failure re-emerges as a typed `C2paError` raised from the `Reader`/`Builder` call that triggered the fetch. An `except MyCustomError:` around `c2pa.Reader(...)` will never fire. Callers should catch `c2pa.C2paError` and read the message. +- **Shape errors are caught early.** Returning a `str` body, a `None` or `bool` status, or a status outside the 100-599 range is rejected inside the trampoline with a clear `TypeError` message, which then surfaces the same way (as a `C2paError`). +- An **empty body** is fine: return `b""` (as `AlwaysFailResolver` does), and the trampoline correctly leaves the native body pointer/length pair empty together. + +## Lifetimes + +- **The resolver outlives `Context.close()`.** Native `Reader`/`Builder` instances hold their own reference to the underlying native context, so the custom resolver can still be invoked after the Python `Context` is closed, for as long as any `Reader` or `Builder` created from it is alive. A custom resolver must not tear down its resources (close a session, release a pool) on `Context.close()`. Those should be tied to the resolver's own lifetime instead. The SDK internally pins the callback thunk to keep this safe, so there is nothing the caller needs to hold onto. +- **No reentrancy.** A custom resolver must not call c2pa APIs from inside `resolve()`. Re-entering the FFI while a call is in flight is undefined. +- **Do not close while a call is in flight.** A `Context`/`Reader`/`Builder` must not be closed while a call is still running on that same object, including a resolver call it triggered. This is a general property of these objects, not something specific to resolvers. + +### A note on closures + +A resolver can be a plain function, a lambda, a bound method, or a `functools.partial`. It runs later on, when the resolver is needed/called. + +- **A closure captures the loop variable itself, not its value at creation time.** For instance, a resolver built inside a `for host in hosts:` loop shares one variable, `host`, across every iteration. By the time any resolver actually runs, the loop has finished and `host` holds whatever it was last set to, so every resolver built that way ends up using the same, final value. Giving the `lambda` its own `host` parameter, defaulted to `host`, fixes it. A default value is evaluated once, right when the `lambda` is created (on that specific loop iteration) — not later, when the `lambda` is finally called. So each `lambda` gets its own parameter holding a frozen copy of that iteration's `host`, shadowing the outer loop variable of the same name, instead of all of them reading the one shared loop variable after the loop has already finished: + + ```py + resolvers = [lambda req, host=host: fetch(host, req) for host in hosts] + ``` + +- **Capturing the `Context` (or a `Reader`/`Builder` made from it) creates a reference cycle that delays cleanup.** The SDK deliberately keeps the resolver alive for as long as the `Context` lives. If the resolver's closure also holds the `Context` (directly, or one step removed through a `Reader`/`Builder`, or via `functools.partial(fn, ctx)`), the two point at each other. Python's cyclic garbage collector still reclaims this — it is not a leak — but the native context handle is released later than expected, at an unpredictable time, instead of the moment the variable goes out of scope. Capturing only what the request actually needs (a session, a config), not the `Context`/`Reader`/`Builder` itself, avoids the cycle entirely: + + ```py + session = make_session() + ctx = c2pa.Context.builder().with_resolver(lambda r: helper(session, r)).build() + ``` + +- **The resolver, and anything it holds, stays alive for the whole `Context` lifetime — even without the application keeping its own reference to it.** With the `resolve()`-method form, the SDK holds the bound method, which holds its object. So an open HTTP session, connection pool, or background thread the resolver owns lives exactly as long as the `Context` does. It should not be relied on to be cleaned up early, and its teardown should not be tied to `Context.close()` — the resolver can still be invoked afterward (see above). Cleanup belongs to the resolver's own lifetime instead. +- **If an object is both callable and has a `resolve()` method, `resolve()` wins.** One shape should be picked: a plain function/lambda, or an object whose `resolve(request)` does the work, not something that is quietly both. + +The trampoline captures only the resolver function plus library internals, never the `Context`. It never creates a cycle on its own: the resolver is held by a strong reference, never a `weakref`, so it can't be garbage-collected mid-call. + +## Platform differences: Linux, Windows, macOS + +Three things can affect the resolver depending on the platform. + +### 1. The C runtime and the response buffer (why a resolver never allocates) + +The response body buffer must be allocated by the **same C runtime whose `free()` the native library calls** (on the native side that is `libc::free`). On Linux and macOS there is effectively one C runtime per process, so "the process's own libc" is always the right allocator. **Windows is different:** a process can host several C runtimes side by side, each with its own heap. The native library links the Universal CRT, so its `free` is `ucrtbase`'s, while the legacy `msvcrt.dll` is a *different* heap. Allocating from one and freeing into the other is heap corruption, and it corrupts silently until it crashes somewhere unrelated. + +This is the reason the bindings carry `ManagedResource._get_native_malloc()` rather than allocating the response buffer from Python's own memory or a plain `ctypes` call. The helper resolves, once and lazily, the exact `malloc` whose heap matches the native library's `free`: + +- On Windows it loads `ucrtbase` first and falls back to `msvcrt`, matching the CRT the native library's `free` uses. +- On Linux and macOS it opens the process's own C library with `ctypes.CDLL(None)`, so its `malloc` and the native `free` come from the same libc. + +The helper also sets `malloc.restype = ctypes.c_void_p`. Without that, `ctypes` assumes a `c_int` return and truncates a 64-bit pointer to 32 bits. The truncated address handed to native `free` is a second, quieter way to corrupt the heap. The looked-up function is cached on the class, so the resolution happens at most once per process. + +The trampoline calls that `malloc`, copies the returned `bytes` into the buffer, and writes the pointer into the native response struct, and native code frees it afterwards. That is the whole reason a custom resolver returns `bytes` and nothing else: **it returns `bytes`, never a pointer, and never memory it allocated with `ctypes` itself.** The reason this rule exists is Windows. + +The response body crosses the Python/native boundary exactly once. Python fills the buffer and hands it over. Native code then frees it and passes the bytes to whatever needed the resource: + +```mermaid +flowchart TD + subgraph PY["Python side"] + A["custom resolver: resolve() returns bytes"] -->|handoff to the trampoline| B{body empty?} + B -- no --> D["trampoline: malloc + memmove into a C buffer"] + B -- yes --> C["trampoline: leave body = NULL, len = 0"] + D --> E["trampoline: write pointer + len into the response struct"] + C --> E + end + E ==>|ownership transfers to native| F + subgraph NA["Native side (SDK)"] + F["copy the body out of the buffer"] --> G["free the buffer"] + G --> H["hand the bytes to whatever needed the resource"] + end +``` + +### 2. TLS trust stores + +The example resolvers delegate to `urllib`, so they inherit Python's `ssl` defaults, which differ by platform: + +- **macOS:** python.org builds do **not** use the system Keychain. If `Install Certificates.command` was never run after installing Python, every HTTPS fetch fails with `CERTIFICATE_VERIFY_FAILED`. Workaround without reinstalling: prefix the run with `SSL_CERT_FILE=$(python -m certifi)`. +- **Linux:** OpenSSL uses the distribution's CA bundle. On a normal desktop this just works. In slim container images the `ca-certificates` package is often missing, producing the same `CERTIFICATE_VERIFY_FAILED`. Install the package or set `SSL_CERT_FILE`. +- **Windows:** Python's `ssl` loads roots from the Windows certificate store, so system-managed (including enterprise-injected) roots are honored automatically. Corporate TLS-interception proxies therefore tend to *work* on Windows and *fail* on macOS/Linux with the same code. If a fetch verifies on one machine and not another, compare trust stores before suspecting the resolver. + +A resolver using a different HTTP stack has that stack's trust behavior instead. TLS trust is resolver-side, per-platform, and invisible to the SDK. + +### 3. Proxies + +`urllib` discovers proxies differently per platform: environment variables (`http_proxy`, `https_proxy`, `no_proxy`) everywhere, **plus** the Windows registry (Internet Settings) on Windows and the System Configuration framework (Network preferences) on macOS. On Linux, environment variables are the only source. So a resolver built on `urllib` silently follows OS-level proxy settings on Windows/macOS but ignores them on Linux, another way the same resolver code behaves differently per machine. For deterministic behavior, a custom resolver should configure the proxy (or its absence) explicitly rather than relying on discovery. + +## Examples and tests + +- [`http_resolver_example_impl.py`](./http_resolver_example_impl.py): the reference shapes (`HttpRequest`, `HttpResponse`, optional `HttpResolver` ABC) and the three resolvers described above. Copy it into a project and adapt it. It does not import `c2pa`. +- [`test_http_resolver_debug.py`](./test_http_resolver_debug.py): exercises `DebugHttpResolver`, verifying that a remote-manifest read logs a `GET`, that signing with a remote-manifest ingredient fetches it through the resolver, and that re-reading the signed (embedded-manifest) output performs no HTTP at all. +- [`test_http_resolver_cache.py`](./test_http_resolver_cache.py): exercises `CachingHttpResolver` (reading twice / ingesting the same ingredient repeatedly hits the cache exactly as the hit/miss counters predict) and `AlwaysFailResolver` (a non-200 answer surfaces as a clean typed `C2paError`, with no network needed). + +Those examples need network access to fetch the remote manifest for `tests/fixtures/cloud.jpg`, and fail without it. + +If the Python runtime has no CA bundle configured, every fetch fails with `CERTIFICATE_VERIFY_FAILED` (see the TLS section above). On macOS python.org builds this is the common case, so the commands below include the fix. + +Run them in a CLI with the commands: + +```bash +python ./tests/http_resolver/test_http_resolver_debug.py +python ./tests/http_resolver/test_http_resolver_cache.py + +# Or the whole directory via unittest discovery: +python -m unittest discover -s tests/http_resolver -v +``` + +On some operating systems, if a CERTIFICATE_VERIFY_FAILED error appears, this prefix on the commands fixes it: + +```bash +SSL_CERT_FILE=$(python -m certifi) python ./tests/http_resolver/test_http_resolver_debug.py +SSL_CERT_FILE=$(python -m certifi) python ./tests/http_resolver/test_http_resolver_cache.py + +# Or the whole directory via unittest discovery: +SSL_CERT_FILE=$(python -m certifi) python -m unittest discover -s tests/http_resolver -v +``` + +## See also + +- [`http_resolver_example_impl.py`](./http_resolver_example_impl.py) — copyable reference resolvers (`DebugHttpResolver`, `CachingHttpResolver`, `AlwaysFailResolver`). +- [`../../docs/context-settings.md`](../../docs/context-settings.md) — settings that gate networking, including `core.allowed_network_hosts` and `verify.remote_manifest_fetch`. +- [`../../docs/native-resources-management.md`](../../docs/native-resources-management.md) — how the SDK manages native handles and their lifetimes, including behavior across process boundaries. diff --git a/tests/http_resolver/__init__.py b/tests/http_resolver/__init__.py new file mode 100644 index 00000000..dcf2c804 --- /dev/null +++ b/tests/http_resolver/__init__.py @@ -0,0 +1 @@ +# Placeholder diff --git a/tests/http_resolver/http_resolver_example_impl.py b/tests/http_resolver/http_resolver_example_impl.py new file mode 100644 index 00000000..e91efda5 --- /dev/null +++ b/tests/http_resolver/http_resolver_example_impl.py @@ -0,0 +1,287 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +"""Example implementations of a custom HTTP resolver for c2pa. + +c2pa ships no HttpRequest/HttpResponse/HttpResolver types: +the resolver passed to ContextBuilder.with_resolver()/Context(resolver=...) +is never isinstance-checked, so any of these classes is a starting point to copy +and adapt, not a required dependency. + +The implementation satisfies the object format/contract the SDK +expects when using custom resolvers. +""" + +import base64 +import collections +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from abc import ABC, abstractmethod + + +def _reject_non_http(url): + """Raise if url's scheme is not http/https. + """ + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in ("http", "https"): + raise ValueError( + f"refusing to resolve non-http(s) URL scheme: {scheme!r}") + + +def _has_opaque_encoded_final_segment(url): + """True if the URL's last path segment looks like an encoded blob + (e.g. an OCSP request) rather than an ordinary resource name. + Such GETs should not be cached like a plain resource fetch. + """ + seg = urllib.parse.urlsplit(url).path.rstrip("/").rsplit("/", 1)[-1] + seg = urllib.parse.unquote(seg) + if len(seg) < 40: + return False + try: + base64.b64decode(seg, validate=True) + except Exception: + return False + return True + + +def _is_safe_to_cache(request): + """Oonly True for a GET positively known to be safe + (not opaque-encoded, not a DID document request). + Anything else (POSTs, ambiguous or unrecognized GETs) + is False by default. + """ + if request.method.upper() != "GET": + return False + if _has_opaque_encoded_final_segment(request.url): + return False + headers = {k.lower(): v for k, v in request.headers.items()} + if "application/did+json" in headers.get("accept", ""): + return False + return True + + +class HttpRequest: + """An HTTP request the SDK asks a custom resolver to perform. + + Attributes: + url: Absolute request URL. + method: HTTP method ("GET", "POST", ...). + headers: Request headers as a dict. Names are lowercased by the + native layer; when a header repeats, the last value wins. + body: Request body bytes (b"" when there is none). A request + carrying a payload (typically a POST) sets this; a plain GET + usually doesn't. + + Reference shape only: + c2pa hands resolve() a duck-typed object with these same four attributes, + not necessarily an instance of this exact class. + A compatible type can be defined separately, or this one reused directly. + """ + __slots__ = ("url", "method", "headers", "body") + + def __init__(self, url: str, method: str, headers: dict, body: bytes): + self.url = url + self.method = method + self.headers = headers + self.body = body + + def __repr__(self): + return (f"HttpRequest(method={self.method!r}, url={self.url!r}, " + f"body_len={len(self.body)})") + + +class HttpResponse: + """The answer a custom resolver returns to the SDK. + + Attributes: + status: HTTP status code. Only 200 is treated as success; any + other code surfaces as a typed C2paError. + body: Response body bytes. + + Reference shape only: + c2pa accepts any object exposing these two attributes, + not necessarily an instance of this exact class. + """ + __slots__ = ("status", "body") + + def __init__(self, status: int, body: bytes = b""): + self.status = status + self.body = body + + def __repr__(self): + return (f"HttpResponse(status={self.status}, " + f"body_len={len(self.body or b'')})") + + +class HttpResolver(ABC): + """Optional base class for custom HTTP resolvers. + + Attach an instance via ContextBuilder.with_resolver() or the + Context(resolver=...) constructor kwarg. + """ + + @abstractmethod + def resolve(self, request: HttpRequest) -> HttpResponse: + """Perform the request. + + Raising instead marks the request as a hard failure, which + surfaces as a typed C2paError. + """ + raise NotImplementedError + + +# The resolvers implemented here are classes that hold their own config in +# __init__ and never capture a Context, Reader, or Builder in a closure. +# This avoids reference-cycles and lifetime issues. +# It also means resolve() never reaches for a mutable global. + + +class TtlLruCache: + """A small LRU cache whose entries also expire after a TTL.""" + + def __init__(self, max_items=100, ttl_seconds=120.0): + self._max_items = int(max_items) + self._ttl = float(ttl_seconds) + self._entries = collections.OrderedDict() # key -> (expiry, value) + self._lock = threading.Lock() + self.hits = 0 + self.misses = 0 + + def get(self, key): + with self._lock: + entry = self._entries.get(key) + if entry is not None and entry[0] > time.monotonic(): + self._entries.move_to_end(key) + self.hits += 1 + return entry[1] + if entry is not None: + del self._entries[key] + self.misses += 1 + return None + + def put(self, key, value): + with self._lock: + if key in self._entries: + self._entries.move_to_end(key) + self._entries[key] = (time.monotonic() + self._ttl, value) + while len(self._entries) > self._max_items: + self._entries.popitem(last=False) + + +class CachingHttpResolver(HttpResolver): + """An HTTP resolver with a response cache and bounded retries. + + Caching policy: only GETs `_is_safe_to_cache` positively clears, + answered with 200, are cached. POSTs, errors, and anything not + positively cleared are never cached (see `_is_safe_to_cache`). + + Retry policy: 429/503 retried up to max_retries, honoring a capped + Retry-After or backing off exponentially. Any other status is final. + Transport errors raise, marking a hard failure. + """ + + def __init__(self, cache=None, timeout=10.0, max_retries=3, + backoff_seconds=0.5, max_retry_after=10.0): + # Capture config here + self.cache = cache if cache is not None else TtlLruCache() + self._timeout = timeout + self._max_retries = int(max_retries) + self._backoff = float(backoff_seconds) + self._max_retry_after = float(max_retry_after) + + def resolve(self, request): + _reject_non_http(request.url) + cacheable = _is_safe_to_cache(request) + if cacheable: + cached = self.cache.get(request.url) + if cached is not None: + return HttpResponse(cached[0], cached[1]) + + status, body = self._fetch_with_retries(request) + if cacheable and status == 200: + self.cache.put(request.url, (status, body)) + return HttpResponse(status, body) + + def _fetch_with_retries(self, request): + data = request.body or None + for attempt in range(self._max_retries + 1): + req = urllib.request.Request( + request.url, + data=data, + method=request.method, + headers=request.headers) + try: + with urllib.request.urlopen( + req, timeout=self._timeout) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + retryable = e.code in (429, 503) + if not retryable or attempt == self._max_retries: + # Pass the status back so the SDK can report it. + return e.code, e.read() + delay = self._retry_delay(e, attempt) + time.sleep(delay) + raise RuntimeError("unreachable") + + def _retry_delay(self, error, attempt): + retry_after = error.headers.get("Retry-After") + if retry_after: + try: + return min(float(retry_after), self._max_retry_after) + except ValueError: + pass + return self._backoff * (2 ** attempt) + + +class AlwaysFailResolver(HttpResolver): + """Answers every request with a fixed status. Needs no network.""" + + def __init__(self, status): + self._status = status + + def resolve(self, request): + return HttpResponse(self._status, b"") + + +class DebugHttpResolver(HttpResolver): + """Logs every SDK HTTP request and response status. + The actual transfer is delegated to urllib. + """ + + def __init__(self, timeout=10.0): + self._timeout = timeout + self.requests = [] + + def resolve(self, request): + _reject_non_http(request.url) + self.requests.append((request.method, request.url)) + + data = request.body or None + req = urllib.request.Request( + request.url, + data=data, + method=request.method, + headers=request.headers) + + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return HttpResponse(resp.status, resp.read()) + except urllib.error.HTTPError as e: + # A 4xx/5xx is still a response. + # Pass the status through and let the SDK turn it + # into its own typed error. + # Only 200 is treated as success. + return HttpResponse(e.code, e.read()) diff --git a/tests/http_resolver/test_http_resolver_cache.py b/tests/http_resolver/test_http_resolver_cache.py new file mode 100644 index 00000000..32b9c14b --- /dev/null +++ b/tests/http_resolver/test_http_resolver_cache.py @@ -0,0 +1,227 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +"""Tests for CachingHttpResolver, an HTTP resolver that caches on read. +""" + +import os +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE)) +sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) + +FIXTURES = os.path.join(_REPO_ROOT, "tests", "fixtures") + +from http_resolver_example_impl import ( # noqa: E402 + AlwaysFailResolver, CachingHttpResolver, HttpRequest) + +import c2pa # noqa: E402 + + +class _StubFetchCachingResolver(CachingHttpResolver): + """CachingHttpResolver with the network swapped for a canned answer, + so the caching *decision* can be tested without a real HTTP call. + """ + + def __init__(self, status=200, body=b"payload"): + super().__init__() + self._canned = (status, body) + self.fetch_count = 0 + + def _fetch_with_retries(self, request): + self.fetch_count += 1 + return self._canned + + +class TestCachingDecision(unittest.TestCase): + """Tests for what CachingHttpResolver will and won't cache. + """ + + def test_opaque_encoded_get_is_never_cached(self): + """A GET whose URL ends in a long opaque token (the shape used by + e.g. a certificate revocation check) is fetched fresh every time, + never served from the cache. + """ + resolver = _StubFetchCachingResolver() + request = HttpRequest( + url="https://ca.example/ocsp/" + ("a" * 60), + method="GET", headers={}, body=b"") + + resolver.resolve(request) + resolver.resolve(request) + + self.assertEqual(resolver.fetch_count, 2) + self.assertEqual(resolver.cache.hits, 0) + + def test_post_is_never_cached(self): + """A request carrying a payload is never replayed from the cache.""" + resolver = _StubFetchCachingResolver() + request = HttpRequest( + url="https://tsa.example/timestamp", method="POST", + headers={"content-type": "application/timestamp-query"}, + body=b"query") + + resolver.resolve(request) + resolver.resolve(request) + + self.assertEqual(resolver.fetch_count, 2) + self.assertEqual(resolver.cache.hits, 0) + + def test_ordinary_get_is_cached(self): + """A plain GET the resolver can't positively rule out is treated + as an ordinary resource fetch and cached, so the fix doesn't + disable caching for the case it's meant to serve. + """ + resolver = _StubFetchCachingResolver() + request = HttpRequest( + url="https://cdn.example/manifests/x.c2pa", method="GET", + headers={}, body=b"") + + resolver.resolve(request) + resolver.resolve(request) + + self.assertEqual(resolver.fetch_count, 1) + self.assertEqual(resolver.cache.hits, 1) + + +class TestHttpResolverCache(unittest.TestCase): + + def test_read_with_cache(self): + """Reading the same remote-manifest asset twice hits the cache. + + Assumption this exact-count assert depends on: one remote-manifest + GET per read, and nothing else on this path (no OCSP, no did:web). + If the SDK ever adds another GET here, this count shifts -- that's + expected and worth updating, not a false alarm. + """ + print('\n--- Reading using an HTTP resolver with a cache') + resolver = CachingHttpResolver() + with c2pa.Context.builder().with_resolver( + resolver).build() as context: + for i in range(2): + print(f"Read #{i + 1} of cloud.jpg") + with open(os.path.join(FIXTURES, "cloud.jpg"), + "rb") as f: + with c2pa.Reader( + "image/jpeg", f, context=context) as reader: + reader.get_validation_state() + print(f"Cache state after read #{i + 1}: " + f"hits={resolver.cache.hits} " + f"misses={resolver.cache.misses}") + + self.assertEqual(resolver.cache.hits, 1) + self.assertEqual(resolver.cache.misses, 1) + + def test_sign_with_cache(self): + """Adding the same remote-manifest ingredient repeatedly hits the + cache. + + Each add re-reads the ingredient's remote manifest through the + context resolver, so three adds mean one network fetch and two + cache hits. + """ + print('\n--- Reading and signing using an HTTP resolver with a cache') + with open(os.path.join(FIXTURES, "es256_certs.pem"), "rb") as f: + certs = f.read() + with open(os.path.join(FIXTURES, "es256_private.key"), "rb") as f: + key = f.read() + + # ta_url is None, meaning "no timestamp authority". + signer_info = c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, private_key=key, ta_url=None) + + ingredient_labels = [f"cloud-ingredient-{i + 1}" for i in range(3)] + + manifest_definition = { + "claim_generator": "http_resolver_cache", + "claim_generator_info": [ + {"name": "http_resolver_cache", "version": "0.1"}], + "format": "image/jpeg", + "title": "Signed with a caching HTTP resolver", + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [ + { + "action": "c2pa.created", + "digitalSourceType": + "http://cv.iptc.org/newscodes/" + "digitalsourcetype/digitalCreation", + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ingredient_labels}, + }, + ]}, + }], + } + + resolver = CachingHttpResolver() + context = (c2pa.Context.builder() + .with_resolver(resolver) + .with_signer(c2pa.Signer.from_info(signer_info)) + .build()) + try: + builder = c2pa.Builder(manifest_definition, context=context) + + with open(os.path.join(FIXTURES, "cloud.jpg"), + "rb") as ingredient: + for index in range(3): + ingredient.seek(0) + print(f"Adding ingredient #{index + 1} of 3 " + f"({ingredient_labels[index]})") + builder.add_ingredient( + {"title": f"cloud.jpg #{index + 1}", + "relationship": "componentOf", + "label": ingredient_labels[index]}, + "image/jpeg", ingredient) + print(f"Cache state: hits={resolver.cache.hits} " + f"misses={resolver.cache.misses}") + + with tempfile.TemporaryDirectory() as output_dir: + output_path = os.path.join( + output_dir, "A_signed_cached.jpg") + with open(os.path.join(FIXTURES, "A.jpg"), + "rb") as source: + with open(output_path, "wb") as dest: + print(f"Signing asset A.jpg -> {output_path}") + builder.sign( + c2pa.Signer.from_info(signer_info), + "image/jpeg", source, dest) + self.assertTrue(os.path.exists(output_path)) + finally: + context.close() + + print(f"Final cache state: hits={resolver.cache.hits} " + f"misses={resolver.cache.misses} " + f"(expected 1 miss, 2 hits, across 3 identical ingredients)") + self.assertEqual(resolver.cache.misses, 1) + self.assertEqual(resolver.cache.hits, 2) + + def test_failing_resolver_is_reported_as_error(self): + """A final failure surfaces as a typed error, not a crash. + Needs no network: the resolver answers without calling out. + """ + with c2pa.Context.builder().with_resolver( + AlwaysFailResolver(500)).build() as context: + with self.assertRaises(c2pa.C2paError) as ctx: + with open(os.path.join(FIXTURES, "cloud.jpg"), "rb") as f: + c2pa.Reader("image/jpeg", f, context=context) + self.assertIn("500", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/http_resolver/test_http_resolver_debug.py b/tests/http_resolver/test_http_resolver_debug.py new file mode 100644 index 00000000..2618a4a4 --- /dev/null +++ b/tests/http_resolver/test_http_resolver_debug.py @@ -0,0 +1,150 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +"""Tests for DebugHttpResolver, an HTTP resolver that logs requests and +delegates the actual transfer to urllib. +""" + +import json +import os +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE)) +sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) + +FIXTURES = os.path.join(_REPO_ROOT, "tests", "fixtures") + +from http_resolver_example_impl import DebugHttpResolver # noqa: E402 + +import c2pa # noqa: E402 + + +class TestHttpResolverDebug(unittest.TestCase): + + def test_read_with_resolver(self): + """Reading an asset whose manifest lives at a remote URL logs a + GET for that fetch.""" + print('\n--- Reading using an HTTP resolver logging requests') + resolver = DebugHttpResolver() + print("Reading using a Context with DebugHttpResolver as HTTP resolver") + with c2pa.Context.builder().with_resolver( + resolver).build() as context: + with open(os.path.join(FIXTURES, "cloud.jpg"), "rb") as f: + with c2pa.Reader( + "image/jpeg", f, context=context) as reader: + reader.get_validation_state() + self.assertFalse(reader.is_embedded()) + self.assertTrue(reader.get_remote_url()) + print(f"Remote manifest url: {reader.get_remote_url()}") + + print(f"Resolver saw {len(resolver.requests)} request(s): " + f"{resolver.requests}") + self.assertTrue( + any(method == "GET" for method, _ in resolver.requests)) + + def test_sign_with_resolver(self): + """Signing an asset with a remote-manifest ingredient logs a GET + for the ingredient's manifest, and reading the signed result back + makes no HTTP requests at all (its manifest is embedded).""" + print('\n--- Reading and signing using an HTTP resolver logging requests') + with open(os.path.join(FIXTURES, "es256_certs.pem"), "rb") as f: + certs = f.read() + with open(os.path.join(FIXTURES, "es256_private.key"), "rb") as f: + key = f.read() + + # ta_url is None, meaning "no timestamp authority". + signer_info = c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, private_key=key, ta_url=None) + + manifest_definition = { + "claim_generator": "http_resolver_debug", + "claim_generator_info": [ + {"name": "http_resolver_debug", "version": "0.1"}], + "format": "image/jpeg", + "title": "Signed with a debug HTTP resolver", + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [ + { + "action": "c2pa.created", + "digitalSourceType": + "http://cv.iptc.org/newscodes/" + "digitalsourcetype/digitalCreation", + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["cloud-ingredient"]}, + }, + ]}, + }], + } + + resolver = DebugHttpResolver() + context = (c2pa.Context.builder() + .with_resolver(resolver) + .with_signer(c2pa.Signer.from_info(signer_info)) + .build()) + try: + builder = c2pa.Builder(manifest_definition, context=context) + + print("Adding cloud.jpg as an ingredient...") + with open(os.path.join(FIXTURES, "cloud.jpg"), + "rb") as ingredient: + builder.add_ingredient( + {"title": "cloud.jpg", "relationship": "componentOf", + "label": "cloud-ingredient"}, + "image/jpeg", ingredient) + print(f"Resolver saw {len(resolver.requests)} request(s) " + f"after add_ingredient: {resolver.requests}") + + with tempfile.TemporaryDirectory() as output_dir: + output_path = os.path.join( + output_dir, "A_signed_resolver.jpg") + with open(os.path.join(FIXTURES, "A.jpg"), + "rb") as source: + with open(output_path, "wb") as dest: + print(f"Signing A.jpg -> {output_path}") + builder.sign( + c2pa.Signer.from_info(signer_info), + "image/jpeg", source, dest) + + self.assertTrue( + any(m == "GET" for m, _ in resolver.requests)) + requests_before_reread = len(resolver.requests) + + # Reading the signed file back uses its embedded manifest, + # so this makes no HTTP requests at all. + print("Re-reading the signed output " + "(should trigger no HTTP requests)") + with open(output_path, "rb") as f: + with c2pa.Reader("image/jpeg", f) as reader: + store = json.loads(reader.json()) + manifest = store["manifests"][ + store["active_manifest"]] + self.assertTrue(manifest.get("ingredients")) + + print(f"Resolver saw {len(resolver.requests)} request(s) " + f"total (expected {requests_before_reread}, " + "unchanged since re-read is embedded-only)") + self.assertEqual( + len(resolver.requests), requests_before_reread) + finally: + context.close() + + +if __name__ == "__main__": + unittest.main()