pyvorin-thin 1.0.9__py3-none-any.whl

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.
@@ -0,0 +1,124 @@
1
+ """
2
+ Pyvorin Thin Client v1
3
+
4
+ Safe thin-client path for licensing, signed-artifact delivery, usage tracking,
5
+ and workload scanning. The compiler is **not** shipped here: the thin client
6
+ activates a licence, obtains a customer/device-bound ``.pyvpkg``, verifies and
7
+ decrypts it, and loads the protected core through a single
8
+ ``ProtectedCoreLoader``. Compilation happens locally on the customer's device;
9
+ customer source is never uploaded.
10
+
11
+ The public surface is a small, stable set of names (below). Everything else
12
+ in the package is internal and may change without notice.
13
+
14
+ Typical use::
15
+
16
+ from pyvorin_thin import ThinCoreClient, ProtectedCoreLoader
17
+ from pyvorin_thin import resolve_runtime_artifact, format_error
18
+ """
19
+
20
+ __version__ = '1.0.9'
21
+ __experimental__ = False
22
+
23
+ from .errors import (
24
+ PVL000_UNKNOWN,
25
+ ActivationError,
26
+ ConfigurationError,
27
+ EntitlementError,
28
+ InstallError,
29
+ IntegrityError,
30
+ PlatformUnsupportedError,
31
+ PythonIncompatibleError,
32
+ SelfTestError,
33
+ ThinClientError,
34
+ TransportError,
35
+ code_for,
36
+ format_error,
37
+ )
38
+ from .runtime_resolver import (
39
+ RuntimeRequirement,
40
+ current_requirement,
41
+ platform_tag,
42
+ python_tag,
43
+ resolve_artifact as resolve_runtime_artifact,
44
+ )
45
+ from .install_identity import (
46
+ Entitlement,
47
+ InstallationIdentity,
48
+ binding_digest,
49
+ device_fingerprint,
50
+ )
51
+ from .pinned_keys import default_pinned_keys
52
+ from .protected_core_loader import (
53
+ CoreLoadError,
54
+ CoreManifest,
55
+ CoreTrustError,
56
+ LoadedCore,
57
+ PinnedKeys,
58
+ ProtectedCoreLoader,
59
+ SignatureVerifier,
60
+ compute_merkle_root,
61
+ )
62
+ from .core_installer import CoreInstaller, InstalledCore, PackageBindingError
63
+ from .core_client import ThinCoreClient
64
+ from .offline import (
65
+ OfflineLicense,
66
+ OfflineRequest,
67
+ generate_request,
68
+ load_stored_licence,
69
+ store_licence,
70
+ version_satisfies,
71
+ )
72
+ from .offline_import import OfflineImporter, OfflineImportResult
73
+
74
+ __all__ = [
75
+ "__version__",
76
+ "__experimental__",
77
+ # error model
78
+ "ThinClientError",
79
+ "TransportError",
80
+ "EntitlementError",
81
+ "ActivationError",
82
+ "PythonIncompatibleError",
83
+ "PlatformUnsupportedError",
84
+ "IntegrityError",
85
+ "InstallError",
86
+ "SelfTestError",
87
+ "ConfigurationError",
88
+ "PVL000_UNKNOWN",
89
+ "code_for",
90
+ "format_error",
91
+ # runtime resolution
92
+ "RuntimeRequirement",
93
+ "current_requirement",
94
+ "platform_tag",
95
+ "python_tag",
96
+ "resolve_runtime_artifact",
97
+ # protected core: identity, trust and load
98
+ "InstallationIdentity",
99
+ "Entitlement",
100
+ "binding_digest",
101
+ "device_fingerprint",
102
+ "default_pinned_keys",
103
+ "PinnedKeys",
104
+ "SignatureVerifier",
105
+ "CoreManifest",
106
+ "CoreTrustError",
107
+ "CoreLoadError",
108
+ "ProtectedCoreLoader",
109
+ "LoadedCore",
110
+ "compute_merkle_root",
111
+ "CoreInstaller",
112
+ "InstalledCore",
113
+ "PackageBindingError",
114
+ "ThinCoreClient",
115
+ # enterprise offline (air-gap) flow
116
+ "OfflineRequest",
117
+ "OfflineLicense",
118
+ "OfflineImporter",
119
+ "OfflineImportResult",
120
+ "generate_request",
121
+ "load_stored_licence",
122
+ "store_licence",
123
+ "version_satisfies",
124
+ ]
@@ -0,0 +1,107 @@
1
+ """
2
+ Local artifact cache for compiled .so files.
3
+
4
+ Keys artifacts by source hash so repeated runs of the same function
5
+ never recompile. Each cache entry stores:
6
+ - {hash}.so : the compiled shared library
7
+ - {hash}.json : metadata (function name, signature, artifact_id, backend)
8
+
9
+ Cache directory: ~/.pyvorin/artifact_cache/
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import pathlib
18
+ from typing import Any, Callable, Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _default_cache_dir() -> pathlib.Path:
24
+ if os.name == "nt":
25
+ appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
26
+ if appdata:
27
+ return pathlib.Path(appdata) / "Pyvorin" / "artifact_cache"
28
+ return pathlib.Path.home() / ".pyvorin" / "artifact_cache"
29
+
30
+
31
+ class ArtifactCache:
32
+ """Disk-backed cache for compiled artifacts."""
33
+
34
+ def __init__(self, cache_dir: Optional[pathlib.Path] = None):
35
+ self.cache_dir = cache_dir or _default_cache_dir()
36
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ def _so_path(self, source_hash: str) -> pathlib.Path:
39
+ return self.cache_dir / f"{source_hash}.so"
40
+
41
+ def _meta_path(self, source_hash: str) -> pathlib.Path:
42
+ return self.cache_dir / f"{source_hash}.json"
43
+
44
+ def get(self, source_hash: str) -> Optional[tuple[pathlib.Path, dict]]:
45
+ """Return (so_path, metadata) if cached, else None."""
46
+ so_path = self._so_path(source_hash)
47
+ meta_path = self._meta_path(source_hash)
48
+ if not so_path.exists() or not meta_path.exists():
49
+ return None
50
+ try:
51
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
52
+ return so_path, meta
53
+ except Exception as exc:
54
+ logger.warning("Corrupt cache entry for %s: %s", source_hash[:16], exc)
55
+ self._remove(source_hash)
56
+ return None
57
+
58
+ def put(
59
+ self,
60
+ source_hash: str,
61
+ artifact_bytes: bytes,
62
+ metadata: dict,
63
+ ) -> pathlib.Path:
64
+ """Store artifact bytes and metadata. Returns path to .so."""
65
+ so_path = self._so_path(source_hash)
66
+ meta_path = self._meta_path(source_hash)
67
+ so_path.write_bytes(artifact_bytes)
68
+ meta_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
69
+ logger.info("Cached artifact %s (%d bytes)", source_hash[:16], len(artifact_bytes))
70
+ return so_path
71
+
72
+ def remove(self, source_hash: str) -> None:
73
+ """Remove a cached entry."""
74
+ self._remove(source_hash)
75
+
76
+ def _remove(self, source_hash: str) -> None:
77
+ for p in (self._so_path(source_hash), self._meta_path(source_hash)):
78
+ try:
79
+ p.unlink(missing_ok=True)
80
+ except OSError:
81
+ pass
82
+
83
+ def clear(self) -> int:
84
+ """Remove all cached artifacts. Returns count removed."""
85
+ count = 0
86
+ for p in self.cache_dir.glob("*"):
87
+ try:
88
+ p.unlink()
89
+ count += 1
90
+ except OSError:
91
+ pass
92
+ logger.info("Cleared %d artifact cache entries", count)
93
+ return count
94
+
95
+ def list_entries(self) -> list[dict]:
96
+ """List all cached entries with metadata."""
97
+ entries = []
98
+ for meta_path in sorted(self.cache_dir.glob("*.json")):
99
+ try:
100
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
101
+ so_path = meta_path.with_suffix(".so")
102
+ meta["_so_exists"] = so_path.exists()
103
+ meta["_so_size"] = so_path.stat().st_size if so_path.exists() else 0
104
+ entries.append(meta)
105
+ except OSError:
106
+ pass
107
+ return entries
@@ -0,0 +1,109 @@
1
+ """
2
+ Encrypted protected-artifact envelope for the Pyvorin thin client.
3
+
4
+ Protected release artifacts (.so compiled kernels, wheels, bundles) are
5
+ stored on the platform encrypted with AES-256-GCM. Only an activated
6
+ device that completes the license-gated download handshake receives the
7
+ per-release decryption key, so copied artifact files are unusable on
8
+ their own.
9
+
10
+ Envelope layout (all integers big-endian)::
11
+
12
+ 0 .. 6 magic b"PYVENC1"
13
+ 7 .. 8 key_id uint16 (server key-ring slot, enables rotation)
14
+ 9 .. 20 nonce 12 bytes
15
+ 21 .. end ciphertext || 16-byte GCM authentication tag
16
+
17
+ The module is intentionally self-contained (no pyvorin.compiler imports)
18
+ so the standalone ``pyvorin_thin`` wheel can use it with only the
19
+ ``cryptography`` package as a dependency.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .errors import IntegrityError
25
+
26
+ import os
27
+ import struct
28
+
29
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
30
+
31
+ MAGIC = b"PYVENC1"
32
+ _HEADER_LEN = len(MAGIC) + 2
33
+ _NONCE_LEN = 12
34
+ _TAG_LEN = 16
35
+ KEY_LEN = 32
36
+
37
+
38
+ class ArtifactCryptoError(IntegrityError):
39
+ """Raised when an encrypted artifact cannot be decrypted or verified."""
40
+
41
+
42
+ def payload_key_id(data: bytes) -> int:
43
+ """Return the key-ring id embedded in an encrypted payload."""
44
+ if len(data) < _HEADER_LEN or not is_encrypted_payload(data):
45
+ raise ArtifactCryptoError("Not an encrypted Pyvorin artifact payload")
46
+ return struct.unpack(">H", data[len(MAGIC):_HEADER_LEN])[0]
47
+
48
+
49
+ def is_encrypted_payload(data: bytes) -> bool:
50
+ """Return True if *data* starts with the PYVENC1 envelope magic."""
51
+ return bool(data) and data[:len(MAGIC)] == MAGIC
52
+
53
+
54
+ def encrypt_payload(plaintext: bytes, key: bytes, key_id: int = 1) -> bytes:
55
+ """Encrypt *plaintext* into a self-describing protected-artifact envelope.
56
+
57
+ Args:
58
+ plaintext: Raw artifact bytes (e.g. a compiled ``.so``).
59
+ key: 32-byte AES-256 key.
60
+ key_id: Server key-ring slot recorded in the envelope header.
61
+
62
+ Returns:
63
+ ``MAGIC || key_id || nonce || ciphertext || tag``.
64
+ """
65
+ if len(key) != KEY_LEN:
66
+ raise ArtifactCryptoError(f"Artifact encryption key must be {KEY_LEN} bytes, got {len(key)}")
67
+ if not 0 <= key_id <= 0xFFFF:
68
+ raise ArtifactCryptoError(f"key_id out of range: {key_id}")
69
+ nonce = os.urandom(_NONCE_LEN)
70
+ ct_with_tag = AESGCM(key).encrypt(nonce, plaintext, None)
71
+ return MAGIC + struct.pack(">H", key_id) + nonce + ct_with_tag
72
+
73
+
74
+ def decrypt_payload(data: bytes, key: bytes) -> bytes:
75
+ """Decrypt and authenticate a PYVENC1 envelope.
76
+
77
+ Raises:
78
+ ArtifactCryptoError: If the payload is malformed, tampered with, or
79
+ the key does not match.
80
+ """
81
+ if not is_encrypted_payload(data):
82
+ raise ArtifactCryptoError("Not an encrypted Pyvorin artifact payload")
83
+ if len(key) != KEY_LEN:
84
+ raise ArtifactCryptoError(f"Artifact decryption key must be {KEY_LEN} bytes, got {len(key)}")
85
+ body = data[_HEADER_LEN:]
86
+ if len(body) < _NONCE_LEN + _TAG_LEN:
87
+ raise ArtifactCryptoError("Encrypted artifact truncated (nonce/tag missing)")
88
+ nonce = body[:_NONCE_LEN]
89
+ ct_with_tag = body[_NONCE_LEN:]
90
+ try:
91
+ return AESGCM(key).decrypt(nonce, ct_with_tag, None)
92
+ except Exception as exc:
93
+ raise ArtifactCryptoError(
94
+ "Artifact decryption failed: tampered data or wrong key"
95
+ ) from exc
96
+
97
+
98
+ def decode_key(key_b64: str) -> bytes:
99
+ """Decode a base64-encoded key from a download response."""
100
+ import base64
101
+ import binascii
102
+
103
+ try:
104
+ key = base64.b64decode(key_b64.strip(), validate=True)
105
+ except (binascii.Error, ValueError) as exc:
106
+ raise ArtifactCryptoError("Invalid base64 artifact key in download response") from exc
107
+ if len(key) != KEY_LEN:
108
+ raise ArtifactCryptoError(f"Decoded artifact key must be {KEY_LEN} bytes, got {len(key)}")
109
+ return key
@@ -0,0 +1,325 @@
1
+ """
2
+ Artifact executor — load a compiled .so and create a Python callable.
3
+
4
+ .. deprecated::
5
+ This is a low-level helper for compiled *kernel* objects. It is **not**
6
+ a route into the protected compiler core: that path is owned solely by
7
+ :class:`pyvorin_thin.protected_core_loader.ProtectedCoreLoader`, which
8
+ verifies the manifest/Merkle chain and the entitlement binding before any
9
+ core byte is executed. Kept for compiled-kernel call sites and their
10
+ existing tests; new code must use ``ProtectedCoreLoader``.
11
+
12
+ Infers function signature from source AST when possible, then binds
13
+ the C function with ctypes. Falls back gracefully when the signature
14
+ cannot be determined or the .so cannot be loaded.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import ctypes
21
+ import json
22
+ import logging
23
+ import os
24
+ import pathlib
25
+ import sys
26
+ from typing import Any, Callable, List, Optional, Tuple
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _resolve_type(annotation: Optional[ast.AST]) -> type:
32
+ """Map a Python type annotation to a ctypes type."""
33
+ if annotation is None:
34
+ return ctypes.c_int64
35
+
36
+ # Handle ast.Name like 'int', 'float', 'str', 'bool'
37
+ if isinstance(annotation, ast.Name):
38
+ name = annotation.id
39
+ if name == "float":
40
+ return ctypes.c_double
41
+ if name == "str":
42
+ return ctypes.c_char_p
43
+ if name == "bool":
44
+ return ctypes.c_bool
45
+ return ctypes.c_int64
46
+
47
+ # Handle ast.Constant for Python 3.8+ style annotations
48
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
49
+ name = annotation.value
50
+ if name == "float":
51
+ return ctypes.c_double
52
+ if name == "str":
53
+ return ctypes.c_char_p
54
+ if name == "bool":
55
+ return ctypes.c_bool
56
+ return ctypes.c_int64
57
+
58
+ return ctypes.c_int64
59
+
60
+
61
+ def infer_signature(
62
+ source: str,
63
+ function_name: str,
64
+ ) -> Optional[Tuple[int, List[type], type, bool]]:
65
+ """
66
+ Parse source and infer the C-compatible signature of *function_name*.
67
+
68
+ Returns (arg_count, arg_types, return_type, returns_bool) or None if
69
+ the function cannot be found.
70
+ """
71
+ try:
72
+ tree = ast.parse(source)
73
+ except SyntaxError as exc:
74
+ logger.warning("Cannot parse source for signature inference: %s", exc)
75
+ return None
76
+
77
+ for node in tree.body:
78
+ if isinstance(node, ast.FunctionDef) and node.name == function_name:
79
+ arg_types = [_resolve_type(arg.annotation) for arg in node.args.args]
80
+ # Ignore *args and **kwargs for C binding
81
+ ret_type = _resolve_type(node.returns)
82
+ returns_bool = (
83
+ isinstance(node.returns, ast.Name) and node.returns.id == "bool"
84
+ ) or (
85
+ isinstance(node.returns, ast.Constant)
86
+ and isinstance(node.returns.value, str)
87
+ and node.returns.value == "bool"
88
+ )
89
+ return len(arg_types), arg_types, ret_type, returns_bool
90
+
91
+ # Try to find the first function if the named one isn't found
92
+ for node in tree.body:
93
+ if isinstance(node, ast.FunctionDef):
94
+ arg_types = [_resolve_type(arg.annotation) for arg in node.args.args]
95
+ ret_type = _resolve_type(node.returns)
96
+ returns_bool = (
97
+ isinstance(node.returns, ast.Name) and node.returns.id == "bool"
98
+ ) or (
99
+ isinstance(node.returns, ast.Constant)
100
+ and isinstance(node.returns.value, str)
101
+ and node.returns.value == "bool"
102
+ )
103
+ return len(arg_types), arg_types, ret_type, returns_bool
104
+
105
+ return None
106
+
107
+
108
+ def _guess_first_function_name(source: str) -> Optional[str]:
109
+ """Return the name of the first top-level function in source."""
110
+ try:
111
+ tree = ast.parse(source)
112
+ except SyntaxError:
113
+ return None
114
+ for node in tree.body:
115
+ if isinstance(node, ast.FunctionDef):
116
+ return node.name
117
+ return None
118
+
119
+
120
+ def is_compat_artifact(artifact_bytes: bytes) -> bool:
121
+ """Return True if the artifact is a JSON compat-mode metadata file."""
122
+ if not artifact_bytes:
123
+ return False
124
+ first = artifact_bytes[0:1]
125
+ return first == b"{" or first == b"["
126
+
127
+
128
+ def _exec_safe_tmp_dir() -> pathlib.Path:
129
+ """Return a temp dir that allows exec (some systems mount /tmp noexec).
130
+
131
+ Prefers ``$TMPDIR``/``$PYVORIN_TMPDIR`` when set, then ``~/.pyvorin/tmp``.
132
+ The dir is created 0700 on first use.
133
+ """
134
+ cand = os.environ.get("PYVORIN_TMPDIR") or os.environ.get("TMPDIR") or ""
135
+ if cand:
136
+ p = pathlib.Path(cand)
137
+ try:
138
+ p.mkdir(parents=True, exist_ok=True)
139
+ return p
140
+ except OSError:
141
+ pass
142
+ p = pathlib.Path.home() / ".pyvorin" / "tmp"
143
+ p.mkdir(parents=True, exist_ok=True)
144
+ try:
145
+ os.chmod(p, 0o700)
146
+ except OSError:
147
+ pass
148
+ return p
149
+
150
+
151
+ def _write_temp_so(data: bytes) -> pathlib.Path:
152
+ """Write artifact bytes to a temp .so and restrict permissions.
153
+
154
+ The file is created in an exec-safe directory (never a noexec /tmp),
155
+ mode 0600. The caller owns cleanup (``unlink`` after ``ctypes.CDLL``
156
+ is safe on Linux: the mapped inode survives unlink).
157
+ """
158
+ import tempfile
159
+ with tempfile.NamedTemporaryFile(suffix=".so", delete=False, dir=_exec_safe_tmp_dir()) as tmp:
160
+ tmp.write(data)
161
+ tmp_path = pathlib.Path(tmp.name)
162
+ try:
163
+ os.chmod(tmp_path, 0o600)
164
+ except OSError:
165
+ pass
166
+ return tmp_path
167
+
168
+
169
+ def load_artifact(
170
+ so_path: pathlib.Path,
171
+ function_name: str,
172
+ arg_types: Optional[List[type]] = None,
173
+ return_type: Optional[type] = None,
174
+ returns_bool: bool = False,
175
+ decryption_key: Optional[bytes] = None,
176
+ ) -> Callable:
177
+ """
178
+ Load a .so and return a callable that invokes the compiled function.
179
+
180
+ Args:
181
+ so_path: Path to the shared library.
182
+ function_name: C symbol name to call.
183
+ arg_types: ctypes types for each argument (defaults to [c_int64, ...]).
184
+ return_type: ctypes return type (defaults to c_int64).
185
+ returns_bool: If True, convert 0/1 result to Python bool.
186
+ decryption_key: 32-byte AES-256 key. When the file is a PYVENC1
187
+ protected envelope it is decrypted to a 0600 temp file that is
188
+ unlinked immediately after ``dlopen``; the plaintext exists
189
+ only as an anonymous in-memory mapping.
190
+
191
+ Raises:
192
+ OSError: If the .so cannot be loaded.
193
+ AttributeError: If the symbol is not found.
194
+ ArtifactCryptoError: If the envelope is tampered or the key is wrong.
195
+ """
196
+ from .artifact_crypto import decrypt_payload, is_encrypted_payload
197
+
198
+ data = pathlib.Path(so_path).read_bytes()
199
+ _encrypted = is_encrypted_payload(data)
200
+ if _encrypted:
201
+ if decryption_key is None:
202
+ raise RuntimeError(
203
+ f"{so_path} is an encrypted protected artifact; a decryption key "
204
+ "from the license-gated download response is required"
205
+ )
206
+ data = decrypt_payload(data, decryption_key)
207
+ # Decrypted plaintext: transient temp file, unlinked right after load.
208
+ eff_path = _write_temp_so(data)
209
+ unlink_after_load = True
210
+ else:
211
+ eff_path = pathlib.Path(so_path)
212
+ unlink_after_load = False
213
+
214
+ lib = ctypes.CDLL(str(eff_path))
215
+ if unlink_after_load:
216
+ try:
217
+ eff_path.unlink()
218
+ except OSError:
219
+ pass
220
+ cfunc = getattr(lib, function_name)
221
+
222
+ effective_argtypes = arg_types or [ctypes.c_int64]
223
+ effective_restype = return_type or ctypes.c_int64
224
+
225
+ cfunc.argtypes = effective_argtypes
226
+ cfunc.restype = effective_restype
227
+
228
+ def wrapper(*args):
229
+ # Convert str args to bytes for c_char_p
230
+ converted = []
231
+ for arg, atype in zip(args, effective_argtypes):
232
+ if atype is ctypes.c_char_p and isinstance(arg, str):
233
+ converted.append(arg.encode("utf-8"))
234
+ else:
235
+ converted.append(arg)
236
+ # Pad with defaults if too few args provided
237
+ while len(converted) < len(effective_argtypes):
238
+ converted.append(0)
239
+ # Trim if too many
240
+ converted = converted[: len(effective_argtypes)]
241
+ result = cfunc(*converted)
242
+ if returns_bool:
243
+ return bool(result)
244
+ # Decode bytes to str for c_char_p return
245
+ if effective_restype is ctypes.c_char_p and isinstance(result, bytes):
246
+ return result.decode("utf-8")
247
+ return result
248
+
249
+ wrapper._lib = lib
250
+ wrapper._so_path = str(so_path)
251
+ wrapper._function_name = function_name
252
+ return wrapper
253
+
254
+
255
+ def execute_artifact(
256
+ artifact_bytes: bytes,
257
+ function_name: str,
258
+ source: str,
259
+ *args: Any,
260
+ decryption_key: Optional[bytes] = None,
261
+ ) -> Any:
262
+ """
263
+ High-level helper: write bytes to a temp .so, infer signature, load, and call.
264
+
265
+ If the bytes are a PYVENC1 encrypted envelope, *decryption_key* (32-byte
266
+ AES-256, from the license-gated download response) is required; the
267
+ decrypted plaintext temp file is unlinked immediately after dlopen.
268
+
269
+ Returns the function result. Raises on failure so the caller can fall back.
270
+ """
271
+ from .artifact_crypto import decrypt_payload, is_encrypted_payload
272
+
273
+ if is_encrypted_payload(artifact_bytes):
274
+ if decryption_key is None:
275
+ raise RuntimeError(
276
+ "Encrypted protected artifact requires a decryption key from "
277
+ "the download response"
278
+ )
279
+ artifact_bytes = decrypt_payload(artifact_bytes, decryption_key)
280
+
281
+ if is_compat_artifact(artifact_bytes):
282
+ meta = json.loads(artifact_bytes)
283
+ logger.info("Compat-mode artifact: %s", meta.get("function_name", "unknown"))
284
+ # For compat mode, we cannot execute the .so — caller should fall back to CPython
285
+ raise RuntimeError("Compat-mode artifact cannot be executed via ctypes")
286
+
287
+ # Write to a temporary .so
288
+ tmp_path = _write_temp_so(artifact_bytes)
289
+
290
+ try:
291
+ sig = infer_signature(source, function_name)
292
+ if sig is None:
293
+ # Fallback: guess function name and assume all-int signature
294
+ guessed = _guess_first_function_name(source)
295
+ if guessed:
296
+ function_name = guessed
297
+ sig = (len(args), [ctypes.c_int64] * max(len(args), 1), ctypes.c_int64, False)
298
+
299
+ arg_count, arg_types, return_type, returns_bool = sig
300
+ # If caller provided args, ensure arg_types length matches
301
+ if args and len(arg_types) < len(args):
302
+ arg_types = [ctypes.c_int64] * len(args)
303
+ if not args and arg_count == 0:
304
+ arg_types = []
305
+
306
+ wrapper = load_artifact(
307
+ tmp_path,
308
+ function_name,
309
+ arg_types=arg_types,
310
+ return_type=return_type,
311
+ returns_bool=returns_bool,
312
+ )
313
+ result = wrapper(*args)
314
+ # The plaintext temp .so can be unlinked as soon as the call chain
315
+ # has dlopen'd it (Linux keeps the inode mapped).
316
+ try:
317
+ tmp_path.unlink()
318
+ except OSError:
319
+ pass
320
+ return result
321
+ finally:
322
+ try:
323
+ tmp_path.unlink()
324
+ except OSError:
325
+ pass