dcert 3.0.35__py3-none-win_amd64.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.
- dcert/__init__.py +91 -0
- dcert/bin/dcert-mcp.exe +0 -0
- dcert/bin/dcert.exe +0 -0
- dcert/checksums.json +4 -0
- dcert/cli.py +263 -0
- dcert/client.py +44 -0
- dcert/download.py +268 -0
- dcert/py.typed +0 -0
- dcert/resilience.py +355 -0
- dcert/server.py +200 -0
- dcert/tools.py +764 -0
- dcert-3.0.35.dist-info/METADATA +202 -0
- dcert-3.0.35.dist-info/RECORD +15 -0
- dcert-3.0.35.dist-info/WHEEL +4 -0
- dcert-3.0.35.dist-info/entry_points.txt +4 -0
dcert/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""dcert: A Python MCP wrapper for the dcert TLS certificate MCP server.
|
|
2
|
+
|
|
3
|
+
This package provides a FastMCP proxy that wraps the dcert-mcp Rust binary,
|
|
4
|
+
automatically exposing all TLS certificate tools via the Model Context Protocol.
|
|
5
|
+
|
|
6
|
+
The proxy pattern means this package requires zero code changes when new
|
|
7
|
+
tools are added to the Rust binary — they are discovered and forwarded
|
|
8
|
+
automatically at runtime via the MCP protocol.
|
|
9
|
+
|
|
10
|
+
Usage as a server:
|
|
11
|
+
from dcert import create_server
|
|
12
|
+
server = create_server()
|
|
13
|
+
server.run()
|
|
14
|
+
|
|
15
|
+
Usage as a client:
|
|
16
|
+
from dcert import create_client
|
|
17
|
+
async with create_client() as client:
|
|
18
|
+
tools = await client.list_tools()
|
|
19
|
+
result = await client.call_tool("analyze_certificate", {"target": "example.com"})
|
|
20
|
+
|
|
21
|
+
Usage with typed async wrappers:
|
|
22
|
+
from dcert.tools import DcertClient
|
|
23
|
+
async with DcertClient() as dcert:
|
|
24
|
+
result = await dcert.analyze_certificate(target="example.com")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__version__ = "3.0.35"
|
|
28
|
+
|
|
29
|
+
from dcert.client import create_client
|
|
30
|
+
from dcert.resilience import (
|
|
31
|
+
CircuitBreaker,
|
|
32
|
+
CircuitBreakerOpen,
|
|
33
|
+
OTelConfig,
|
|
34
|
+
RateLimiter,
|
|
35
|
+
ResilienceConfig,
|
|
36
|
+
setup_otel,
|
|
37
|
+
truncate_response,
|
|
38
|
+
)
|
|
39
|
+
from dcert.server import create_server
|
|
40
|
+
from dcert.tools import (
|
|
41
|
+
DcertClient,
|
|
42
|
+
DcertConnectionError,
|
|
43
|
+
DcertError,
|
|
44
|
+
DcertTimeoutError,
|
|
45
|
+
DcertToolError,
|
|
46
|
+
analyze_certificate,
|
|
47
|
+
check_expiry,
|
|
48
|
+
check_revocation,
|
|
49
|
+
compare_certificates,
|
|
50
|
+
convert_pem_to_pfx,
|
|
51
|
+
convert_pfx_to_pem,
|
|
52
|
+
create_keystore,
|
|
53
|
+
create_truststore,
|
|
54
|
+
export_pem,
|
|
55
|
+
tls_connection_info,
|
|
56
|
+
verify_key_match,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Core API
|
|
61
|
+
"create_server",
|
|
62
|
+
"create_client",
|
|
63
|
+
"__version__",
|
|
64
|
+
# Client
|
|
65
|
+
"DcertClient",
|
|
66
|
+
# Exceptions
|
|
67
|
+
"DcertError",
|
|
68
|
+
"DcertTimeoutError",
|
|
69
|
+
"DcertConnectionError",
|
|
70
|
+
"DcertToolError",
|
|
71
|
+
# Resilience
|
|
72
|
+
"ResilienceConfig",
|
|
73
|
+
"OTelConfig",
|
|
74
|
+
"CircuitBreaker",
|
|
75
|
+
"CircuitBreakerOpen",
|
|
76
|
+
"RateLimiter",
|
|
77
|
+
"setup_otel",
|
|
78
|
+
"truncate_response",
|
|
79
|
+
# Tool wrappers
|
|
80
|
+
"analyze_certificate",
|
|
81
|
+
"check_expiry",
|
|
82
|
+
"check_revocation",
|
|
83
|
+
"compare_certificates",
|
|
84
|
+
"tls_connection_info",
|
|
85
|
+
"export_pem",
|
|
86
|
+
"verify_key_match",
|
|
87
|
+
"convert_pfx_to_pem",
|
|
88
|
+
"convert_pem_to_pfx",
|
|
89
|
+
"create_keystore",
|
|
90
|
+
"create_truststore",
|
|
91
|
+
]
|
dcert/bin/dcert-mcp.exe
ADDED
|
Binary file
|
dcert/bin/dcert.exe
ADDED
|
Binary file
|
dcert/checksums.json
ADDED
dcert/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""CLI entry points for dcert.
|
|
2
|
+
|
|
3
|
+
Provides three commands:
|
|
4
|
+
- ``dcert``: Thin wrapper that execs the bundled Rust ``dcert`` binary.
|
|
5
|
+
- ``dcert-mcp``: Thin wrapper that execs the bundled Rust ``dcert-mcp`` binary.
|
|
6
|
+
- ``dcert-python``: Python MCP proxy server wrapping the Rust binary via FastMCP.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_python_script(path: str) -> bool:
|
|
20
|
+
"""Check if *path* is a Python console-script wrapper (not a compiled binary).
|
|
21
|
+
|
|
22
|
+
Reads the first 128 bytes; if the file starts with ``#!`` and the first
|
|
23
|
+
line contains ``python``, it is a pip-generated console_script wrapper
|
|
24
|
+
and must be skipped to avoid an infinite exec loop (see helm-mcp PR #33).
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
with open(path, "rb") as fh:
|
|
28
|
+
head = fh.read(128)
|
|
29
|
+
first_line = head.split(b"\n", 1)[0].lower()
|
|
30
|
+
return head[:2] == b"#!" and b"python" in first_line
|
|
31
|
+
except OSError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_bundled_binary(name: str) -> str | None:
|
|
36
|
+
"""Locate a binary bundled inside the package ``bin/`` directory.
|
|
37
|
+
|
|
38
|
+
If the binary exists but is not executable, it is chmod'd on first use.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Absolute path to the binary, or ``None`` if not found.
|
|
42
|
+
"""
|
|
43
|
+
pkg_dir = Path(__file__).parent
|
|
44
|
+
bundled = pkg_dir / "bin" / name
|
|
45
|
+
# On Windows the bundled binary carries a .exe suffix (dcert.exe).
|
|
46
|
+
if not bundled.is_file() and sys.platform == "win32" and not name.endswith(".exe"):
|
|
47
|
+
win_bundled = pkg_dir / "bin" / f"{name}.exe"
|
|
48
|
+
if win_bundled.is_file():
|
|
49
|
+
bundled = win_bundled
|
|
50
|
+
if not bundled.is_file():
|
|
51
|
+
return None
|
|
52
|
+
# Ensure the binary is executable (pip may not preserve permissions
|
|
53
|
+
# for package-data files extracted from wheels).
|
|
54
|
+
if not os.access(str(bundled), os.X_OK):
|
|
55
|
+
try:
|
|
56
|
+
bundled.chmod(bundled.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
return str(bundled)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _find_binary(name: str) -> str:
|
|
63
|
+
"""Find a binary by name: bundled in package, then PATH, then auto-download.
|
|
64
|
+
|
|
65
|
+
Skips Python console-script wrappers on PATH to avoid infinite exec
|
|
66
|
+
loops when pip installs the universal wheel.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
FileNotFoundError: If the binary cannot be located.
|
|
70
|
+
"""
|
|
71
|
+
# 1. Bundled binary inside the Python package
|
|
72
|
+
bundled = _find_bundled_binary(name)
|
|
73
|
+
if bundled:
|
|
74
|
+
return bundled
|
|
75
|
+
|
|
76
|
+
# 2. Binary on PATH — skip Python console-script wrappers
|
|
77
|
+
found = shutil.which(name)
|
|
78
|
+
if found and not _is_python_script(found):
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
# 3. Auto-download from GitHub Releases (fallback for universal wheel)
|
|
82
|
+
from dcert import __version__
|
|
83
|
+
from dcert.download import ensure_binary
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
downloaded = ensure_binary(__version__)
|
|
87
|
+
if downloaded:
|
|
88
|
+
return downloaded
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
f"{name} binary not found. Install dcert via:\n"
|
|
94
|
+
" brew tap SCGIS-Wales/tap && brew install dcert\n"
|
|
95
|
+
" or: pip install dcert (platform wheel bundles the binary)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def dcert_main() -> None:
|
|
100
|
+
"""Entry point for the ``dcert`` command.
|
|
101
|
+
|
|
102
|
+
Locates the bundled Rust ``dcert`` binary and replaces the current
|
|
103
|
+
process with it, forwarding all command-line arguments.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
binary = _find_binary("dcert")
|
|
107
|
+
except FileNotFoundError as e:
|
|
108
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
os.execvp(binary, [binary] + sys.argv[1:])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def dcert_mcp_main() -> None:
|
|
114
|
+
"""Entry point for the ``dcert-mcp`` command.
|
|
115
|
+
|
|
116
|
+
Locates the bundled Rust ``dcert-mcp`` binary and replaces the current
|
|
117
|
+
process with it, forwarding all command-line arguments.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
binary = _find_binary("dcert-mcp")
|
|
121
|
+
except FileNotFoundError as e:
|
|
122
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
os.execvp(binary, [binary] + sys.argv[1:])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
"""Run the dcert MCP proxy server (``dcert-python`` command)."""
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
description="dcert: MCP server for TLS certificate analysis",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--transport",
|
|
134
|
+
choices=["stdio", "http", "sse"],
|
|
135
|
+
default="stdio",
|
|
136
|
+
help="Transport mode (default: stdio)",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--host",
|
|
140
|
+
default="0.0.0.0",
|
|
141
|
+
help="Host for HTTP/SSE mode (default: 0.0.0.0)",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--port",
|
|
145
|
+
type=int,
|
|
146
|
+
default=8080,
|
|
147
|
+
help="Port for HTTP/SSE mode (default: 8080)",
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--binary",
|
|
151
|
+
default=None,
|
|
152
|
+
help="Path to dcert-mcp binary (auto-detected if not set)",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--setup",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="Download the dcert-mcp binary and exit",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# -- Resiliency flags --
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--no-retry",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="Disable automatic retry on connection errors",
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--no-circuit-breaker",
|
|
168
|
+
action="store_true",
|
|
169
|
+
help="Disable circuit breaker",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--rate-limit",
|
|
173
|
+
type=float,
|
|
174
|
+
default=None,
|
|
175
|
+
metavar="RPS",
|
|
176
|
+
help="Enable rate limiting at RPS requests per second",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--cache",
|
|
180
|
+
action="store_true",
|
|
181
|
+
help="Enable response caching",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--bulkhead-max",
|
|
185
|
+
type=int,
|
|
186
|
+
default=None,
|
|
187
|
+
metavar="N",
|
|
188
|
+
help="Maximum concurrent tool calls (default: 10)",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# -- OpenTelemetry --
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--otel",
|
|
194
|
+
action="store_true",
|
|
195
|
+
help="Enable OpenTelemetry tracing",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--otel-exporter",
|
|
199
|
+
choices=["console", "otlp"],
|
|
200
|
+
default=None,
|
|
201
|
+
help="OpenTelemetry exporter (default: console)",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
args = parser.parse_args()
|
|
205
|
+
|
|
206
|
+
if args.setup:
|
|
207
|
+
from dcert import __version__
|
|
208
|
+
from dcert.download import ensure_binary
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
path = ensure_binary(__version__)
|
|
212
|
+
if path:
|
|
213
|
+
print(f"dcert-mcp binary ready at: {path}")
|
|
214
|
+
else:
|
|
215
|
+
print(
|
|
216
|
+
"No checksums available for this platform. Install the binary manually.",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"Error downloading binary: {e}", file=sys.stderr)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Apply CLI overrides to environment so ResilienceConfig picks them up
|
|
226
|
+
if args.no_retry:
|
|
227
|
+
os.environ["DCERT_MCP_NO_RETRY"] = "1"
|
|
228
|
+
if args.no_circuit_breaker:
|
|
229
|
+
os.environ["DCERT_MCP_NO_CIRCUIT_BREAKER"] = "1"
|
|
230
|
+
if args.rate_limit is not None:
|
|
231
|
+
os.environ["DCERT_MCP_RATE_LIMIT_ENABLED"] = "1"
|
|
232
|
+
os.environ["DCERT_MCP_RATE_LIMIT_RPS"] = str(args.rate_limit)
|
|
233
|
+
if args.cache:
|
|
234
|
+
os.environ["DCERT_MCP_CACHE_ENABLED"] = "1"
|
|
235
|
+
if args.bulkhead_max is not None:
|
|
236
|
+
os.environ["DCERT_MCP_BULKHEAD_MAX"] = str(args.bulkhead_max)
|
|
237
|
+
|
|
238
|
+
# OpenTelemetry
|
|
239
|
+
if args.otel:
|
|
240
|
+
os.environ["DCERT_MCP_OTEL_ENABLED"] = "1"
|
|
241
|
+
if args.otel_exporter is not None:
|
|
242
|
+
os.environ["DCERT_MCP_OTEL_EXPORTER"] = args.otel_exporter
|
|
243
|
+
|
|
244
|
+
from dcert.resilience import OTelConfig, setup_otel
|
|
245
|
+
|
|
246
|
+
setup_otel(OTelConfig())
|
|
247
|
+
|
|
248
|
+
from dcert.server import create_server
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
server = create_server(binary_path=args.binary)
|
|
252
|
+
except FileNotFoundError as e:
|
|
253
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
if args.transport == "stdio":
|
|
257
|
+
server.run()
|
|
258
|
+
else:
|
|
259
|
+
server.run(transport=args.transport, host=args.host, port=args.port)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
dcert/client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""FastMCP client for connecting to the dcert-mcp Rust binary.
|
|
2
|
+
|
|
3
|
+
Provides a thin client wrapper that connects to the Rust binary via stdio
|
|
4
|
+
transport. All tool discovery is handled by the MCP protocol at runtime,
|
|
5
|
+
so new tools added to the binary are automatically available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastmcp import Client
|
|
11
|
+
from fastmcp.client.transports import StdioTransport
|
|
12
|
+
|
|
13
|
+
from dcert.server import _build_subprocess_env, _find_binary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_client(
|
|
17
|
+
binary_path: str | None = None,
|
|
18
|
+
env: dict[str, str] | None = None,
|
|
19
|
+
) -> Client:
|
|
20
|
+
"""Create a FastMCP client connected to the dcert-mcp Rust binary via stdio.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
binary_path: Explicit path to the dcert-mcp binary. Auto-detected if ``None``.
|
|
24
|
+
env: Additional environment variables to pass to the subprocess.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A FastMCP ``Client`` instance. Use as an async context manager.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
async with create_client() as client:
|
|
32
|
+
tools = await client.list_tools()
|
|
33
|
+
result = await client.call_tool(
|
|
34
|
+
"analyze_certificate", {"target": "example.com"}
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
binary = binary_path or _find_binary()
|
|
38
|
+
subprocess_env = _build_subprocess_env(extra_env=env)
|
|
39
|
+
transport = StdioTransport(
|
|
40
|
+
command=binary,
|
|
41
|
+
args=[],
|
|
42
|
+
env=subprocess_env or None,
|
|
43
|
+
)
|
|
44
|
+
return Client(transport)
|
dcert/download.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Auto-download dcert binaries from GitHub Releases.
|
|
2
|
+
|
|
3
|
+
Downloads the platform-appropriate tar.gz archive, verifies its SHA256
|
|
4
|
+
checksum against values embedded in the package (checksums.json), extracts
|
|
5
|
+
the ``dcert`` and ``dcert-mcp`` binaries, and installs them to a
|
|
6
|
+
PATH-accessible directory.
|
|
7
|
+
|
|
8
|
+
Supply chain security:
|
|
9
|
+
- Checksums are baked into the wheel at build time, not fetched at runtime.
|
|
10
|
+
- Downloads use HTTPS with certificate verification.
|
|
11
|
+
- Archive is written to a temp file and only extracted after checksum passes.
|
|
12
|
+
- No shell commands or install-time hooks are used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import shutil
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
import sysconfig
|
|
26
|
+
import tarfile
|
|
27
|
+
import tempfile
|
|
28
|
+
import urllib.request
|
|
29
|
+
import zipfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
GITHUB_RELEASE_URL = (
|
|
35
|
+
"https://github.com/SCGIS-Wales/dcert/releases/download/v{version}/{archive_name}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Timeout for the HTTP connection and read (seconds).
|
|
39
|
+
DOWNLOAD_TIMEOUT = 60
|
|
40
|
+
|
|
41
|
+
# Maps (system, machine) to Rust target triple
|
|
42
|
+
PLATFORM_MAP: dict[tuple[str, str], str] = {
|
|
43
|
+
("darwin", "arm64"): "aarch64-apple-darwin",
|
|
44
|
+
("darwin", "x86_64"): "x86_64-apple-darwin",
|
|
45
|
+
("linux", "x86_64"): "x86_64-unknown-linux-gnu",
|
|
46
|
+
("windows", "amd64"): "x86_64-pc-windows-msvc",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_windows() -> bool:
|
|
51
|
+
return platform.system().lower() == "windows"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bin_filename(stem: str) -> str:
|
|
55
|
+
"""Return the on-disk binary filename for *stem* (adds .exe on Windows)."""
|
|
56
|
+
return f"{stem}.exe" if _is_windows() else stem
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_checksums() -> dict:
|
|
60
|
+
"""Load embedded checksums from package data.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Parsed checksums dict, or empty dict if file is missing.
|
|
64
|
+
"""
|
|
65
|
+
checksums_path = Path(__file__).parent / "checksums.json"
|
|
66
|
+
if not checksums_path.exists():
|
|
67
|
+
return {}
|
|
68
|
+
with open(checksums_path) as f:
|
|
69
|
+
return json.load(f)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_target_triple() -> str | None:
|
|
73
|
+
"""Get the Rust target triple for the current platform.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Target triple like ``aarch64-apple-darwin``, or ``None`` if
|
|
77
|
+
the platform is not supported.
|
|
78
|
+
"""
|
|
79
|
+
system = platform.system().lower()
|
|
80
|
+
machine = platform.machine().lower()
|
|
81
|
+
return PLATFORM_MAP.get((system, machine))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_archive_name() -> str | None:
|
|
85
|
+
"""Get the platform-specific archive filename.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Archive name like ``dcert-aarch64-apple-darwin.tar.gz``,
|
|
89
|
+
or ``None`` if the platform is not supported.
|
|
90
|
+
"""
|
|
91
|
+
triple = _get_target_triple()
|
|
92
|
+
if triple is None:
|
|
93
|
+
return None
|
|
94
|
+
# Windows binaries are shipped as .zip (PowerShell-friendly); other
|
|
95
|
+
# platforms use .tar.gz.
|
|
96
|
+
ext = "zip" if _is_windows() else "tar.gz"
|
|
97
|
+
return f"dcert-{triple}.{ext}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_install_dir() -> Path:
|
|
101
|
+
"""Get the best directory for installing the binaries.
|
|
102
|
+
|
|
103
|
+
Prefers the Python scripts directory (where pip puts console_scripts,
|
|
104
|
+
which is on PATH). Falls back to ``~/.local/bin``.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Writable directory path.
|
|
108
|
+
"""
|
|
109
|
+
scripts_dir = Path(sysconfig.get_path("scripts"))
|
|
110
|
+
if os.access(str(scripts_dir), os.W_OK):
|
|
111
|
+
return scripts_dir
|
|
112
|
+
# Fallback: user-local bin
|
|
113
|
+
local_bin = Path.home() / ".local" / "bin"
|
|
114
|
+
local_bin.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
return local_bin
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _verify_checksum(file_path: Path, expected_sha256: str) -> bool:
|
|
119
|
+
"""Verify SHA256 checksum of a file.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
file_path: Path to the file to verify.
|
|
123
|
+
expected_sha256: Expected hex-encoded SHA256 digest.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if checksum matches, False otherwise.
|
|
127
|
+
"""
|
|
128
|
+
sha256 = hashlib.sha256()
|
|
129
|
+
with open(file_path, "rb") as f:
|
|
130
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
131
|
+
sha256.update(chunk)
|
|
132
|
+
return sha256.hexdigest() == expected_sha256
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _extract_binaries(archive_path: Path, install_dir: Path) -> Path:
|
|
136
|
+
"""Extract dcert and dcert-mcp binaries from a downloaded archive.
|
|
137
|
+
|
|
138
|
+
Handles both .tar.gz (Linux/macOS) and .zip (Windows) archives. On
|
|
139
|
+
Windows the binaries are named ``dcert.exe`` / ``dcert-mcp.exe``.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
archive_path: Path to the downloaded .tar.gz or .zip file.
|
|
143
|
+
install_dir: Directory to install binaries into.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Path to the installed dcert-mcp binary.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
RuntimeError: If the dcert-mcp binary is not found in the archive.
|
|
150
|
+
"""
|
|
151
|
+
is_win = _is_windows()
|
|
152
|
+
mcp_name = _bin_filename("dcert-mcp")
|
|
153
|
+
wanted = {_bin_filename("dcert"), mcp_name}
|
|
154
|
+
found_mcp = False
|
|
155
|
+
|
|
156
|
+
def _emit(name: str, file_obj) -> None:
|
|
157
|
+
nonlocal found_mcp
|
|
158
|
+
# Write contents manually rather than using extract() to avoid path
|
|
159
|
+
# traversal — member names could contain "../" even after Path.name.
|
|
160
|
+
target = install_dir / name
|
|
161
|
+
with open(target, "wb") as out:
|
|
162
|
+
shutil.copyfileobj(file_obj, out)
|
|
163
|
+
if not is_win:
|
|
164
|
+
target.chmod(target.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
165
|
+
if name == mcp_name:
|
|
166
|
+
found_mcp = True
|
|
167
|
+
|
|
168
|
+
if archive_path.suffix == ".zip" or is_win:
|
|
169
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
170
|
+
for member in zf.infolist():
|
|
171
|
+
name = Path(member.filename).name
|
|
172
|
+
if name in wanted:
|
|
173
|
+
with zf.open(member) as file_obj:
|
|
174
|
+
_emit(name, file_obj)
|
|
175
|
+
else:
|
|
176
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
177
|
+
for member in tar.getmembers():
|
|
178
|
+
name = Path(member.name).name
|
|
179
|
+
if name in wanted:
|
|
180
|
+
file_obj = tar.extractfile(member)
|
|
181
|
+
if file_obj is None:
|
|
182
|
+
continue
|
|
183
|
+
_emit(name, file_obj)
|
|
184
|
+
|
|
185
|
+
if not found_mcp:
|
|
186
|
+
raise RuntimeError("dcert-mcp binary not found in archive")
|
|
187
|
+
|
|
188
|
+
return install_dir / mcp_name
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ensure_binary(version: str) -> str | None:
|
|
192
|
+
"""Ensure the dcert-mcp binary is available, downloading if needed.
|
|
193
|
+
|
|
194
|
+
If the binary already exists in the install directory and is executable,
|
|
195
|
+
returns its path immediately. Otherwise downloads the platform archive
|
|
196
|
+
from GitHub Releases, verifies the SHA256 checksum, extracts the
|
|
197
|
+
binaries, and installs them.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
version: Package version (e.g. ``"3.0.12"``). Used to construct
|
|
201
|
+
the download URL and match against checksums.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Absolute path to the dcert-mcp binary, or ``None`` if download
|
|
205
|
+
is not possible (e.g. no checksums available for this platform).
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
RuntimeError: If the downloaded archive fails checksum verification.
|
|
209
|
+
urllib.error.URLError: If the download fails.
|
|
210
|
+
"""
|
|
211
|
+
install_dir = _get_install_dir()
|
|
212
|
+
target = install_dir / _bin_filename("dcert-mcp")
|
|
213
|
+
|
|
214
|
+
# Already installed?
|
|
215
|
+
if target.exists() and os.access(str(target), os.X_OK):
|
|
216
|
+
return str(target)
|
|
217
|
+
|
|
218
|
+
# Load embedded checksums
|
|
219
|
+
archive_name = _get_archive_name()
|
|
220
|
+
if archive_name is None:
|
|
221
|
+
logger.debug(
|
|
222
|
+
"Unsupported platform %s/%s — skipping auto-download",
|
|
223
|
+
platform.system(),
|
|
224
|
+
platform.machine(),
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
checksums = _load_checksums()
|
|
229
|
+
expected = checksums.get("archives", {}).get(archive_name)
|
|
230
|
+
if not expected:
|
|
231
|
+
logger.debug("No checksum for %s — skipping auto-download", archive_name)
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
url = GITHUB_RELEASE_URL.format(version=version, archive_name=archive_name)
|
|
235
|
+
logger.info("Downloading dcert binaries from %s", url)
|
|
236
|
+
print(
|
|
237
|
+
f"Downloading dcert binaries for {platform.system()}/{platform.machine()}...",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Download to temp file, verify checksum, then extract
|
|
242
|
+
fd, tmp_path = tempfile.mkstemp(dir=str(install_dir), prefix=".dcert-")
|
|
243
|
+
try:
|
|
244
|
+
os.close(fd)
|
|
245
|
+
with (
|
|
246
|
+
urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as resp,
|
|
247
|
+
open(tmp_path, "wb") as out,
|
|
248
|
+
):
|
|
249
|
+
shutil.copyfileobj(resp, out)
|
|
250
|
+
|
|
251
|
+
if not _verify_checksum(Path(tmp_path), expected):
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
f"Checksum mismatch for {archive_name}. "
|
|
254
|
+
"The downloaded archive does not match the expected hash. "
|
|
255
|
+
"This could indicate a tampered download."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Extract binaries from the verified archive
|
|
259
|
+
mcp_path = _extract_binaries(Path(tmp_path), install_dir)
|
|
260
|
+
|
|
261
|
+
print(f"Installed dcert binaries to {install_dir}", file=sys.stderr)
|
|
262
|
+
logger.info("Installed dcert binaries to %s", install_dir)
|
|
263
|
+
return str(mcp_path)
|
|
264
|
+
except Exception:
|
|
265
|
+
# Clean up temp file on any failure
|
|
266
|
+
if os.path.exists(tmp_path):
|
|
267
|
+
os.unlink(tmp_path)
|
|
268
|
+
raise
|
dcert/py.typed
ADDED
|
File without changes
|