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.
- pyvorin_thin/__init__.py +124 -0
- pyvorin_thin/artifact_cache.py +107 -0
- pyvorin_thin/artifact_crypto.py +109 -0
- pyvorin_thin/artifact_executor.py +325 -0
- pyvorin_thin/benchmark_event.py +262 -0
- pyvorin_thin/billing_cap.py +166 -0
- pyvorin_thin/cli.py +1333 -0
- pyvorin_thin/cli_core.py +195 -0
- pyvorin_thin/cli_offline.py +219 -0
- pyvorin_thin/config.py +262 -0
- pyvorin_thin/core_client.py +357 -0
- pyvorin_thin/core_installer.py +383 -0
- pyvorin_thin/errors.py +138 -0
- pyvorin_thin/failure_event.py +208 -0
- pyvorin_thin/install_identity.py +275 -0
- pyvorin_thin/license_client.py +194 -0
- pyvorin_thin/offline.py +545 -0
- pyvorin_thin/offline_import.py +242 -0
- pyvorin_thin/offline_queue.py +164 -0
- pyvorin_thin/pinned_keys.py +60 -0
- pyvorin_thin/protected_core_loader.py +747 -0
- pyvorin_thin/pyvpkg.py +621 -0
- pyvorin_thin/release_client.py +414 -0
- pyvorin_thin/reporting.py +100 -0
- pyvorin_thin/runner.py +208 -0
- pyvorin_thin/runtime_installer.py +375 -0
- pyvorin_thin/runtime_resolver.py +185 -0
- pyvorin_thin/schemas/usage_event.schema.json +127 -0
- pyvorin_thin/usage.py +288 -0
- pyvorin_thin/usage_client.py +344 -0
- pyvorin_thin/workload_scan.py +208 -0
- pyvorin_thin-1.0.9.dist-info/METADATA +100 -0
- pyvorin_thin-1.0.9.dist-info/RECORD +36 -0
- pyvorin_thin-1.0.9.dist-info/WHEEL +5 -0
- pyvorin_thin-1.0.9.dist-info/entry_points.txt +3 -0
- pyvorin_thin-1.0.9.dist-info/top_level.txt +1 -0
pyvorin_thin/__init__.py
ADDED
|
@@ -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
|