codebase-memory-mcp 0.12.0__tar.gz → 0.12.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/PKG-INFO +3 -1
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/README.md +2 -0
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/pyproject.toml +1 -1
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/_cli.py +98 -148
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/tests/test_cli.py +216 -149
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/.gitignore +0 -0
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/requirements-publish.txt +0 -0
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__init__.py +0 -0
- {codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebase-memory-mcp
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Fast code intelligence for AI coding agents — installs a verified native runtime set
|
|
5
5
|
Project-URL: Homepage, https://github.com/DeusData/codebase-memory-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/DeusData/codebase-memory-mcp
|
|
@@ -38,6 +38,8 @@ pipx install codebase-memory-mcp
|
|
|
38
38
|
|
|
39
39
|
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
|
|
40
40
|
|
|
41
|
+
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
|
|
42
|
+
|
|
41
43
|
## Usage
|
|
42
44
|
|
|
43
45
|
```bash
|
|
@@ -16,6 +16,8 @@ pipx install codebase-memory-mcp
|
|
|
16
16
|
|
|
17
17
|
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
|
|
18
18
|
|
|
19
|
+
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
|
|
20
|
+
|
|
19
21
|
## Usage
|
|
20
22
|
|
|
21
23
|
```bash
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "codebase-memory-mcp"
|
|
11
|
-
version = "0.12.
|
|
11
|
+
version = "0.12.1"
|
|
12
12
|
description = "Fast code intelligence for AI coding agents — installs a verified native runtime set"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = { text = "MIT" }
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Downloads codebase-memory-mcp on first run, then runs its native entry point."""
|
|
2
2
|
|
|
3
|
-
import collections
|
|
4
3
|
import errno
|
|
5
4
|
import hashlib
|
|
6
5
|
import json
|
|
@@ -39,26 +38,19 @@ _WINDOWS_ARCHIVE_NAMES = (
|
|
|
39
38
|
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
|
40
39
|
# could otherwise turn a download into an arbitrary-local-file read.
|
|
41
40
|
_ALLOWED_SCHEMES = frozenset({"https"})
|
|
41
|
+
# install.sh's loopback pattern: plain HTTP only to an exact loopback
|
|
42
|
+
# authority, for local smoke tests (CBM_DOWNLOAD_URL).
|
|
43
|
+
_LOOPBACK_HTTP_URL = re.compile(
|
|
44
|
+
r"http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?"
|
|
45
|
+
)
|
|
46
|
+
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
42
47
|
_MAX_REDIRECTS = 5
|
|
43
48
|
_NETWORK_TIMEOUT_SECONDS = 120
|
|
44
49
|
_CANDIDATE_TIMEOUT_SECONDS = 15
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
# (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper (install.js).
|
|
48
|
-
_MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024
|
|
50
|
+
# Entry-count guard for the archive namespace check. Keep in sync with the
|
|
51
|
+
# Go wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper.
|
|
49
52
|
_MAX_ARCHIVE_MEMBERS = 64
|
|
50
|
-
_MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024
|
|
51
|
-
_MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024
|
|
52
53
|
_ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024
|
|
53
|
-
_ArchiveLimits = collections.namedtuple(
|
|
54
|
-
"_ArchiveLimits", "compressed_bytes members member_bytes expanded_bytes"
|
|
55
|
-
)
|
|
56
|
-
_DEFAULT_ARCHIVE_LIMITS = _ArchiveLimits(
|
|
57
|
-
compressed_bytes=_MAX_RELEASE_ARCHIVE_BYTES,
|
|
58
|
-
members=_MAX_ARCHIVE_MEMBERS,
|
|
59
|
-
member_bytes=_MAX_ARCHIVE_MEMBER_BYTES,
|
|
60
|
-
expanded_bytes=_MAX_ARCHIVE_EXPANDED_BYTES,
|
|
61
|
-
)
|
|
62
54
|
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
|
63
55
|
_RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
|
|
64
56
|
_RUNTIME_LOCK_WAIT_SECONDS = 45
|
|
@@ -79,13 +71,50 @@ _runtime_lock_claim_observer = None
|
|
|
79
71
|
|
|
80
72
|
|
|
81
73
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
82
|
-
"""Expose redirects to
|
|
74
|
+
"""Expose redirects to _download_file for explicit per-hop validation."""
|
|
83
75
|
|
|
84
76
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
85
77
|
return None
|
|
86
78
|
|
|
87
79
|
|
|
88
80
|
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
81
|
+
# A loopback fetch never goes through a proxy.
|
|
82
|
+
_LOOPBACK_OPENER = urllib.request.build_opener(
|
|
83
|
+
urllib.request.ProxyHandler({}), _NoRedirectHandler()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _download_base(version: str, environ=None):
|
|
88
|
+
"""Return (base, loopback) for release downloads.
|
|
89
|
+
|
|
90
|
+
CBM_DOWNLOAD_URL replaces the release download base (for testing), with
|
|
91
|
+
install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
|
|
92
|
+
"""
|
|
93
|
+
env = os.environ if environ is None else environ
|
|
94
|
+
override = env.get("CBM_DOWNLOAD_URL")
|
|
95
|
+
if not override:
|
|
96
|
+
return f"https://github.com/{REPO}/releases/download/v{version}", False
|
|
97
|
+
if override.startswith("https://"):
|
|
98
|
+
return override, False
|
|
99
|
+
if _LOOPBACK_HTTP_URL.fullmatch(override):
|
|
100
|
+
return override, True
|
|
101
|
+
sys.exit(
|
|
102
|
+
f"codebase-memory-mcp: refusing non-HTTPS download URL "
|
|
103
|
+
f"(CBM_DOWNLOAD_URL): {override}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_loopback_url(url: str) -> None:
|
|
108
|
+
"""Reject anything but plain HTTP to an exact loopback authority."""
|
|
109
|
+
parsed = urllib.parse.urlparse(url)
|
|
110
|
+
if (
|
|
111
|
+
not _LOOPBACK_HTTP_URL.fullmatch(url)
|
|
112
|
+
or parsed.scheme != "http"
|
|
113
|
+
or parsed.hostname not in _LOOPBACK_HOSTNAMES
|
|
114
|
+
or parsed.username is not None
|
|
115
|
+
or parsed.password is not None
|
|
116
|
+
):
|
|
117
|
+
sys.exit(f"codebase-memory-mcp: refusing non-loopback HTTP URL: {url}")
|
|
89
118
|
|
|
90
119
|
|
|
91
120
|
def _validate_url_scheme(url: str) -> None:
|
|
@@ -103,23 +132,33 @@ def _validate_url_scheme(url: str) -> None:
|
|
|
103
132
|
)
|
|
104
133
|
|
|
105
134
|
|
|
106
|
-
def
|
|
107
|
-
|
|
135
|
+
def _validate_download_url(url: str, loopback: bool) -> None:
|
|
136
|
+
if loopback:
|
|
137
|
+
_validate_loopback_url(url)
|
|
138
|
+
else:
|
|
139
|
+
_validate_url_scheme(url)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _download_file(url: str, dest: str, loopback: bool = False) -> None:
|
|
143
|
+
"""Download into dest: HTTPS with checked redirects, or one loopback hop."""
|
|
144
|
+
opener = _LOOPBACK_OPENER if loopback else _HTTPS_OPENER
|
|
108
145
|
current_url = url
|
|
109
146
|
for redirect_count in range(_MAX_REDIRECTS + 1):
|
|
110
|
-
|
|
147
|
+
_validate_download_url(current_url, loopback)
|
|
111
148
|
request = urllib.request.Request(
|
|
112
149
|
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
|
113
150
|
)
|
|
114
151
|
try:
|
|
115
|
-
response =
|
|
116
|
-
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
|
117
|
-
)
|
|
152
|
+
response = opener.open(request, timeout=_NETWORK_TIMEOUT_SECONDS)
|
|
118
153
|
except urllib.error.HTTPError as exc:
|
|
119
154
|
if exc.code not in _REDIRECT_CODES:
|
|
120
155
|
raise
|
|
121
156
|
location = exc.headers.get("Location")
|
|
122
157
|
exc.close()
|
|
158
|
+
if loopback:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"refusing redirect from loopback download: {current_url}"
|
|
161
|
+
)
|
|
123
162
|
if not location:
|
|
124
163
|
raise RuntimeError(f"redirect has no Location: {current_url}")
|
|
125
164
|
if redirect_count == _MAX_REDIRECTS:
|
|
@@ -129,18 +168,8 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
|
|
129
168
|
continue
|
|
130
169
|
|
|
131
170
|
with response:
|
|
132
|
-
|
|
133
|
-
declared = (response.headers.get("Content-Length") or "").strip()
|
|
134
|
-
# Only plain ASCII digits are a length (str.isdigit also accepts
|
|
135
|
-
# characters that int() rejects); anything else is left to the
|
|
136
|
-
# byte counter below.
|
|
137
|
-
if max_bytes and re.fullmatch(r"[0-9]+", declared):
|
|
138
|
-
if int(declared) > max_bytes:
|
|
139
|
-
raise RuntimeError(
|
|
140
|
-
f"download exceeds the {max_bytes}-byte safety limit"
|
|
141
|
-
)
|
|
171
|
+
_validate_download_url(response.geturl(), loopback)
|
|
142
172
|
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
|
143
|
-
total = 0
|
|
144
173
|
try:
|
|
145
174
|
with open(dest, "wb") as out:
|
|
146
175
|
while True:
|
|
@@ -151,12 +180,6 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
|
|
151
180
|
chunk = response.read(65536)
|
|
152
181
|
if not chunk:
|
|
153
182
|
return
|
|
154
|
-
total += len(chunk)
|
|
155
|
-
if max_bytes and total > max_bytes:
|
|
156
|
-
raise RuntimeError(
|
|
157
|
-
f"download exceeds the {max_bytes}-byte "
|
|
158
|
-
f"safety limit"
|
|
159
|
-
)
|
|
160
183
|
out.write(chunk)
|
|
161
184
|
except BaseException:
|
|
162
185
|
# Never leave a partial download behind for a later step to
|
|
@@ -236,106 +259,51 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
|
|
|
236
259
|
)
|
|
237
260
|
|
|
238
261
|
|
|
239
|
-
def
|
|
240
|
-
if (
|
|
241
|
-
limits.compressed_bytes <= 0
|
|
242
|
-
or limits.members <= 0
|
|
243
|
-
or limits.member_bytes <= 0
|
|
244
|
-
or limits.expanded_bytes <= 0
|
|
245
|
-
):
|
|
246
|
-
sys.exit("codebase-memory-mcp: invalid archive resource safety limits")
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def _require_compressed_archive_within_limit(archive_path: str, limits) -> None:
|
|
250
|
-
status = os.stat(archive_path)
|
|
251
|
-
if not stat.S_ISREG(status.st_mode):
|
|
262
|
+
def _require_regular_archive(archive_path: str) -> None:
|
|
263
|
+
if not stat.S_ISREG(os.stat(archive_path).st_mode):
|
|
252
264
|
sys.exit("codebase-memory-mcp: release archive is not a regular file")
|
|
253
|
-
if status.st_size > limits.compressed_bytes:
|
|
254
|
-
sys.exit(
|
|
255
|
-
f"codebase-memory-mcp: release archive exceeds the "
|
|
256
|
-
f"{limits.compressed_bytes}-byte compressed safety limit"
|
|
257
|
-
)
|
|
258
265
|
|
|
259
266
|
|
|
260
|
-
def _require_member_count_within_limit(count: int,
|
|
261
|
-
if count >
|
|
267
|
+
def _require_member_count_within_limit(count: int, max_members: int) -> None:
|
|
268
|
+
if count > max_members:
|
|
262
269
|
sys.exit(
|
|
263
|
-
f"codebase-memory-mcp: archive exceeds the {
|
|
270
|
+
f"codebase-memory-mcp: archive exceeds the {max_members}-member "
|
|
264
271
|
f"safety limit"
|
|
265
272
|
)
|
|
266
273
|
|
|
267
274
|
|
|
268
|
-
def
|
|
269
|
-
name: str, size: int, declared_expanded: int, limits
|
|
270
|
-
) -> int:
|
|
271
|
-
"""Check one member's declared size; return the new declared total."""
|
|
275
|
+
def _require_declared_size(name: str, size: int) -> None:
|
|
272
276
|
if size < 0:
|
|
273
277
|
sys.exit(
|
|
274
278
|
f"codebase-memory-mcp: archive member has a negative declared "
|
|
275
279
|
f"size: {name!r}"
|
|
276
280
|
)
|
|
277
|
-
if size > limits.member_bytes:
|
|
278
|
-
sys.exit(
|
|
279
|
-
f"codebase-memory-mcp: archive member {name!r} exceeds the "
|
|
280
|
-
f"{limits.member_bytes}-byte expanded safety limit"
|
|
281
|
-
)
|
|
282
|
-
if declared_expanded > limits.expanded_bytes - size:
|
|
283
|
-
sys.exit(
|
|
284
|
-
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
|
|
285
|
-
f"aggregate expanded safety limit"
|
|
286
|
-
)
|
|
287
|
-
return declared_expanded + size
|
|
288
281
|
|
|
289
282
|
|
|
290
|
-
def
|
|
291
|
-
|
|
292
|
-
) -> int:
|
|
293
|
-
"""Copy a member while counting bytes; return the new actual total.
|
|
283
|
+
def _copy_archive_member(source, output, name: str, declared_size: int) -> int:
|
|
284
|
+
"""Copy a member, then require the declared size; return the bytes copied.
|
|
294
285
|
|
|
295
|
-
|
|
296
|
-
limits, so a member that yields more than it declares can never fill the
|
|
297
|
-
disk. The caller removes the target on failure.
|
|
286
|
+
The caller removes the target on failure.
|
|
298
287
|
"""
|
|
299
|
-
remaining = limits.expanded_bytes - actual_expanded
|
|
300
|
-
copy_limit = min(limits.member_bytes, remaining)
|
|
301
288
|
copied = 0
|
|
302
289
|
while True:
|
|
303
|
-
|
|
304
|
-
if wanted <= 0:
|
|
305
|
-
break
|
|
306
|
-
chunk = source.read(wanted)
|
|
290
|
+
chunk = source.read(_ARCHIVE_COPY_CHUNK_BYTES)
|
|
307
291
|
if not chunk:
|
|
308
292
|
break
|
|
309
293
|
copied += len(chunk)
|
|
310
|
-
if copied > copy_limit:
|
|
311
|
-
break
|
|
312
294
|
output.write(chunk)
|
|
313
|
-
if copied > limits.member_bytes:
|
|
314
|
-
sys.exit(
|
|
315
|
-
f"codebase-memory-mcp: archive member {name!r} exceeds the "
|
|
316
|
-
f"{limits.member_bytes}-byte actual expanded safety limit"
|
|
317
|
-
)
|
|
318
|
-
if copied > remaining:
|
|
319
|
-
sys.exit(
|
|
320
|
-
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
|
|
321
|
-
f"aggregate actual expanded safety limit"
|
|
322
|
-
)
|
|
323
295
|
if copied != declared_size:
|
|
324
296
|
sys.exit(
|
|
325
297
|
f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
|
|
326
298
|
f"does not match declared size {declared_size}"
|
|
327
299
|
)
|
|
328
|
-
return
|
|
300
|
+
return copied
|
|
329
301
|
|
|
330
302
|
|
|
331
|
-
def
|
|
332
|
-
source, target: str, name: str, declared_size: int, actual_expanded: int, limits
|
|
333
|
-
) -> int:
|
|
303
|
+
def _write_archive_member(source, target: str, name: str, declared_size: int) -> int:
|
|
334
304
|
try:
|
|
335
305
|
with open(target, "xb") as output:
|
|
336
|
-
return
|
|
337
|
-
source, output, name, declared_size, actual_expanded, limits
|
|
338
|
-
)
|
|
306
|
+
return _copy_archive_member(source, output, name, declared_size)
|
|
339
307
|
except BaseException:
|
|
340
308
|
_remove_quietly(target)
|
|
341
309
|
raise
|
|
@@ -347,36 +315,29 @@ def _safe_extract_tar(
|
|
|
347
315
|
archive_names=(),
|
|
348
316
|
extract_names=(),
|
|
349
317
|
ui: bool = False,
|
|
350
|
-
|
|
318
|
+
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
|
351
319
|
):
|
|
352
320
|
"""Validate a tar namespace, then extract only its authenticated runtime set."""
|
|
353
|
-
_validate_archive_limits(limits)
|
|
354
321
|
members = tf.getmembers()
|
|
355
|
-
_require_member_count_within_limit(len(members),
|
|
356
|
-
declared_expanded = 0
|
|
322
|
+
_require_member_count_within_limit(len(members), max_members)
|
|
357
323
|
for member in members:
|
|
358
324
|
if not member.isfile():
|
|
359
325
|
sys.exit(
|
|
360
326
|
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
361
327
|
f"(not a regular root file: {member.name!r})"
|
|
362
328
|
)
|
|
363
|
-
|
|
364
|
-
member.name, member.size, declared_expanded, limits
|
|
365
|
-
)
|
|
329
|
+
_require_declared_size(member.name, member.size)
|
|
366
330
|
_validate_archive_names([member.name for member in members], archive_names)
|
|
367
331
|
runtime_names = list(extract_names)
|
|
368
332
|
by_name = {member.name: member for member in members}
|
|
369
333
|
dest_abs = os.path.abspath(dest)
|
|
370
|
-
actual_expanded = 0
|
|
371
334
|
for name in runtime_names:
|
|
372
335
|
source = tf.extractfile(by_name[name])
|
|
373
336
|
if source is None:
|
|
374
337
|
sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
|
|
375
338
|
target = os.path.join(dest_abs, name)
|
|
376
339
|
with source:
|
|
377
|
-
|
|
378
|
-
source, target, name, by_name[name].size, actual_expanded, limits
|
|
379
|
-
)
|
|
340
|
+
_write_archive_member(source, target, name, by_name[name].size)
|
|
380
341
|
return tuple(runtime_names)
|
|
381
342
|
|
|
382
343
|
|
|
@@ -386,7 +347,7 @@ def _safe_extract_zip(
|
|
|
386
347
|
archive_names=(),
|
|
387
348
|
extract_names=(),
|
|
388
349
|
ui: bool = False,
|
|
389
|
-
|
|
350
|
+
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
|
390
351
|
):
|
|
391
352
|
"""Extract a zipfile after validating its Windows-style namespace.
|
|
392
353
|
|
|
@@ -394,16 +355,12 @@ def _safe_extract_zip(
|
|
|
394
355
|
members before extraction prevents archive order from selecting the binary
|
|
395
356
|
that a portable package-manager shim eventually executes.
|
|
396
357
|
"""
|
|
397
|
-
_validate_archive_limits(limits)
|
|
398
358
|
dest_abs = os.path.abspath(dest)
|
|
399
359
|
names = []
|
|
400
360
|
infos = zf.infolist()
|
|
401
|
-
_require_member_count_within_limit(len(infos),
|
|
402
|
-
declared_expanded = 0
|
|
361
|
+
_require_member_count_within_limit(len(infos), max_members)
|
|
403
362
|
for info in infos:
|
|
404
|
-
|
|
405
|
-
info.filename, info.file_size, declared_expanded, limits
|
|
406
|
-
)
|
|
363
|
+
_require_declared_size(info.filename, info.file_size)
|
|
407
364
|
raw_name = info.filename
|
|
408
365
|
name = raw_name.replace("\\", "/")
|
|
409
366
|
segments_name = name[:-1] if name.endswith("/") else name
|
|
@@ -437,30 +394,24 @@ def _safe_extract_zip(
|
|
|
437
394
|
# Extract only the validated runtime files. This avoids relying on
|
|
438
395
|
# platform-specific zip path rewriting and always creates regular files.
|
|
439
396
|
runtime_names = list(extract_names)
|
|
440
|
-
actual_expanded = 0
|
|
441
397
|
for name in runtime_names:
|
|
442
398
|
target = os.path.join(dest_abs, name)
|
|
443
399
|
with zf.open(name) as source:
|
|
444
|
-
|
|
445
|
-
source,
|
|
446
|
-
target,
|
|
447
|
-
name,
|
|
448
|
-
zf.getinfo(name).file_size,
|
|
449
|
-
actual_expanded,
|
|
450
|
-
limits,
|
|
451
|
-
)
|
|
400
|
+
_write_archive_member(source, target, name, zf.getinfo(name).file_size)
|
|
452
401
|
return tuple(runtime_names)
|
|
453
402
|
|
|
454
403
|
|
|
455
|
-
def _verify_checksum(
|
|
456
|
-
|
|
457
|
-
|
|
404
|
+
def _verify_checksum(
|
|
405
|
+
archive_path: str, archive_name: str, base: str, loopback: bool = False
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Verify SHA256 checksum against checksums.txt from the same base."""
|
|
408
|
+
url = f"{base}/checksums.txt"
|
|
458
409
|
tmp_path = None
|
|
459
410
|
try:
|
|
460
|
-
|
|
411
|
+
_validate_download_url(url, loopback)
|
|
461
412
|
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
|
462
413
|
tmp_path = tmp.name
|
|
463
|
-
|
|
414
|
+
_download_file(url, tmp_path, loopback)
|
|
464
415
|
expected = None
|
|
465
416
|
with open(tmp_path, encoding="utf-8") as f:
|
|
466
417
|
for line in f:
|
|
@@ -517,7 +468,7 @@ def _version() -> str:
|
|
|
517
468
|
from importlib.metadata import version
|
|
518
469
|
return version("codebase-memory-mcp")
|
|
519
470
|
except Exception:
|
|
520
|
-
return "0.
|
|
471
|
+
return "0.12.0"
|
|
521
472
|
|
|
522
473
|
|
|
523
474
|
def _os_name() -> str:
|
|
@@ -1344,8 +1295,9 @@ def _download(version: str) -> Path:
|
|
|
1344
1295
|
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
|
|
1345
1296
|
portable = "-portable" if os_name == "linux" else ""
|
|
1346
1297
|
archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
|
1347
|
-
|
|
1348
|
-
|
|
1298
|
+
base, loopback = _download_base(version)
|
|
1299
|
+
url = f"{base}/{archive}"
|
|
1300
|
+
_validate_download_url(url, loopback)
|
|
1349
1301
|
|
|
1350
1302
|
dest = _bin_path(version)
|
|
1351
1303
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1358,7 +1310,7 @@ def _download(version: str) -> Path:
|
|
|
1358
1310
|
with tempfile.TemporaryDirectory() as tmp:
|
|
1359
1311
|
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
|
1360
1312
|
try:
|
|
1361
|
-
|
|
1313
|
+
_download_file(url, tmp_archive, loopback)
|
|
1362
1314
|
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
|
1363
1315
|
sys.exit(
|
|
1364
1316
|
f"codebase-memory-mcp: download failed ({e})\n"
|
|
@@ -1366,10 +1318,8 @@ def _download(version: str) -> Path:
|
|
|
1366
1318
|
f"See https://github.com/{REPO}/releases for available versions."
|
|
1367
1319
|
)
|
|
1368
1320
|
|
|
1369
|
-
_verify_checksum(tmp_archive, archive,
|
|
1370
|
-
|
|
1371
|
-
tmp_archive, _DEFAULT_ARCHIVE_LIMITS
|
|
1372
|
-
)
|
|
1321
|
+
_verify_checksum(tmp_archive, archive, base, loopback)
|
|
1322
|
+
_require_regular_archive(tmp_archive)
|
|
1373
1323
|
|
|
1374
1324
|
bin_name = (
|
|
1375
1325
|
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import errno
|
|
2
3
|
import hashlib
|
|
4
|
+
import http.server
|
|
3
5
|
import io
|
|
4
6
|
import json
|
|
5
7
|
import os
|
|
@@ -840,9 +842,10 @@ _cli._publish_runtime_set(
|
|
|
840
842
|
class _FakeHttpsResponse:
|
|
841
843
|
"""Minimal stand-in for the opener's response: headers, geturl, read."""
|
|
842
844
|
|
|
843
|
-
def __init__(self, body: bytes, headers=None):
|
|
845
|
+
def __init__(self, body: bytes, headers=None, fail_after_first_chunk=False):
|
|
844
846
|
self._body = body
|
|
845
847
|
self._offset = 0
|
|
848
|
+
self._fail_after_first_chunk = fail_after_first_chunk
|
|
846
849
|
self.headers = dict(headers or {})
|
|
847
850
|
|
|
848
851
|
def __enter__(self):
|
|
@@ -855,6 +858,8 @@ class _FakeHttpsResponse:
|
|
|
855
858
|
return "https://example.invalid/release.tar.gz"
|
|
856
859
|
|
|
857
860
|
def read(self, size=-1):
|
|
861
|
+
if self._fail_after_first_chunk and self._offset:
|
|
862
|
+
raise OSError("connection reset")
|
|
858
863
|
if size is None or size < 0:
|
|
859
864
|
size = len(self._body) - self._offset
|
|
860
865
|
chunk = self._body[self._offset : self._offset + size]
|
|
@@ -862,8 +867,31 @@ class _FakeHttpsResponse:
|
|
|
862
867
|
return chunk
|
|
863
868
|
|
|
864
869
|
|
|
865
|
-
|
|
866
|
-
|
|
870
|
+
class _ZeroSource:
|
|
871
|
+
"""Reader that yields `size` zero bytes without holding them."""
|
|
872
|
+
|
|
873
|
+
def __init__(self, size: int):
|
|
874
|
+
self._remaining = size
|
|
875
|
+
|
|
876
|
+
def read(self, size=-1):
|
|
877
|
+
if size is None or size < 0:
|
|
878
|
+
size = self._remaining
|
|
879
|
+
count = min(size, self._remaining)
|
|
880
|
+
self._remaining -= count
|
|
881
|
+
return bytes(count)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class _CountingSink:
|
|
885
|
+
def __init__(self):
|
|
886
|
+
self.written = 0
|
|
887
|
+
|
|
888
|
+
def write(self, chunk):
|
|
889
|
+
self.written += len(chunk)
|
|
890
|
+
return len(chunk)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# The size cap the 0.12.0 wrappers enforced, which refused every release.
|
|
894
|
+
_FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024
|
|
867
895
|
|
|
868
896
|
|
|
869
897
|
def _write_tar_gz(archive, entries):
|
|
@@ -884,58 +912,32 @@ def _release_entries(names):
|
|
|
884
912
|
return [(name, f"payload of {name}".encode()) for name in names]
|
|
885
913
|
|
|
886
914
|
|
|
887
|
-
class
|
|
915
|
+
class ArchiveExtractionTests(unittest.TestCase):
|
|
888
916
|
URL = "https://example.invalid/release.tar.gz"
|
|
889
917
|
|
|
890
|
-
def _download(self, response, destination
|
|
918
|
+
def _download(self, response, destination):
|
|
891
919
|
with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
|
|
892
|
-
_cli.
|
|
893
|
-
|
|
894
|
-
def test_download_rejects_a_declared_length_over_the_limit(self):
|
|
895
|
-
with tempfile.TemporaryDirectory() as root:
|
|
896
|
-
destination = os.path.join(root, "release.tar.gz")
|
|
897
|
-
response = _FakeHttpsResponse(b"", {"Content-Length": "9"})
|
|
898
|
-
with self.assertRaises(RuntimeError) as raised:
|
|
899
|
-
self._download(response, destination, 8)
|
|
900
|
-
self.assertIn("8-byte safety limit", str(raised.exception))
|
|
901
|
-
self.assertFalse(os.path.exists(destination))
|
|
920
|
+
_cli._download_file(self.URL, destination)
|
|
902
921
|
|
|
903
|
-
def
|
|
922
|
+
def test_download_stores_a_body_whose_declared_length_exceeds_the_former_cap(
|
|
904
923
|
self,
|
|
905
924
|
):
|
|
906
925
|
with tempfile.TemporaryDirectory() as root:
|
|
907
926
|
destination = os.path.join(root, "release.tar.gz")
|
|
908
|
-
response = _FakeHttpsResponse(
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
self.
|
|
912
|
-
self.assertFalse(os.path.exists(destination))
|
|
913
|
-
|
|
914
|
-
def test_download_within_the_limit_stores_the_body(self):
|
|
915
|
-
with tempfile.TemporaryDirectory() as root:
|
|
916
|
-
destination = os.path.join(root, "release.tar.gz")
|
|
917
|
-
response = _FakeHttpsResponse(b"payload", {"Content-Length": "7"})
|
|
918
|
-
self._download(response, destination, 1024)
|
|
927
|
+
response = _FakeHttpsResponse(
|
|
928
|
+
b"payload", {"Content-Length": str(_FORMER_MEMBER_CAP_BYTES + 1)}
|
|
929
|
+
)
|
|
930
|
+
self._download(response, destination)
|
|
919
931
|
with open(destination, "rb") as stored:
|
|
920
932
|
self.assertEqual(stored.read(), b"payload")
|
|
921
933
|
|
|
922
|
-
def
|
|
923
|
-
# A superscript two passes str.isdigit but not int(); such values and
|
|
924
|
-
# plain junk must fall through to the byte counter, never crash.
|
|
925
|
-
for declared in ("²", "abc", "", "1e3", "-1"):
|
|
926
|
-
with self.subTest(declared=declared), tempfile.TemporaryDirectory() as root:
|
|
927
|
-
destination = os.path.join(root, "release.tar.gz")
|
|
928
|
-
response = _FakeHttpsResponse(b"x" * 1024, {"Content-Length": declared})
|
|
929
|
-
with self.assertRaises(RuntimeError) as raised:
|
|
930
|
-
self._download(response, destination, 8)
|
|
931
|
-
self.assertIn("8-byte safety limit", str(raised.exception))
|
|
932
|
-
self.assertFalse(os.path.exists(destination))
|
|
934
|
+
def test_download_removes_the_partial_file_when_the_body_fails(self):
|
|
933
935
|
with tempfile.TemporaryDirectory() as root:
|
|
934
936
|
destination = os.path.join(root, "release.tar.gz")
|
|
935
|
-
response = _FakeHttpsResponse(b"
|
|
936
|
-
self.
|
|
937
|
-
|
|
938
|
-
|
|
937
|
+
response = _FakeHttpsResponse(b"x" * 200000, fail_after_first_chunk=True)
|
|
938
|
+
with self.assertRaises(OSError):
|
|
939
|
+
self._download(response, destination)
|
|
940
|
+
self.assertFalse(os.path.exists(destination))
|
|
939
941
|
|
|
940
942
|
def test_unix_tar_rejects_more_members_than_the_limit(self):
|
|
941
943
|
with tempfile.TemporaryDirectory() as root:
|
|
@@ -949,50 +951,12 @@ class ArchiveLimitTests(unittest.TestCase):
|
|
|
949
951
|
str(destination),
|
|
950
952
|
("one", "two", "three"),
|
|
951
953
|
("one",),
|
|
952
|
-
|
|
954
|
+
max_members=2,
|
|
953
955
|
)
|
|
954
956
|
self.assertIn("2-member safety limit", str(raised.exception))
|
|
955
957
|
self.assertFalse((destination / "one").exists())
|
|
956
958
|
|
|
957
|
-
def
|
|
958
|
-
with tempfile.TemporaryDirectory() as root:
|
|
959
|
-
archive = Path(root) / "member.tar.gz"
|
|
960
|
-
_write_tar_gz(archive, [("codebase-memory-mcp", b"x" * 65)])
|
|
961
|
-
destination = Path(root) / "extract"
|
|
962
|
-
destination.mkdir()
|
|
963
|
-
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
|
964
|
-
_cli._safe_extract_tar(
|
|
965
|
-
tf,
|
|
966
|
-
str(destination),
|
|
967
|
-
("codebase-memory-mcp",),
|
|
968
|
-
("codebase-memory-mcp",),
|
|
969
|
-
limits=_limits_with(member_bytes=64),
|
|
970
|
-
)
|
|
971
|
-
self.assertIn(
|
|
972
|
-
"'codebase-memory-mcp' exceeds the 64-byte expanded safety limit",
|
|
973
|
-
str(raised.exception),
|
|
974
|
-
)
|
|
975
|
-
self.assertFalse((destination / "codebase-memory-mcp").exists())
|
|
976
|
-
|
|
977
|
-
def test_unix_tar_rejects_a_declared_aggregate_over_the_limit(self):
|
|
978
|
-
with tempfile.TemporaryDirectory() as root:
|
|
979
|
-
archive = Path(root) / "aggregate.tar.gz"
|
|
980
|
-
_write_tar_gz(archive, [("one", b"a" * 40), ("two", b"b" * 40)])
|
|
981
|
-
destination = Path(root) / "extract"
|
|
982
|
-
destination.mkdir()
|
|
983
|
-
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
|
984
|
-
_cli._safe_extract_tar(
|
|
985
|
-
tf,
|
|
986
|
-
str(destination),
|
|
987
|
-
("one", "two"),
|
|
988
|
-
("one",),
|
|
989
|
-
limits=_limits_with(member_bytes=64, expanded_bytes=64),
|
|
990
|
-
)
|
|
991
|
-
self.assertIn(
|
|
992
|
-
"64-byte aggregate expanded safety limit", str(raised.exception)
|
|
993
|
-
)
|
|
994
|
-
|
|
995
|
-
def test_unix_tar_within_the_limits_extracts_only_the_executable(self):
|
|
959
|
+
def test_unix_tar_extracts_only_the_executable(self):
|
|
996
960
|
with tempfile.TemporaryDirectory() as root:
|
|
997
961
|
archive = Path(root) / "release.tar.gz"
|
|
998
962
|
_write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
|
|
@@ -1012,67 +976,29 @@ class ArchiveLimitTests(unittest.TestCase):
|
|
|
1012
976
|
)
|
|
1013
977
|
self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
|
|
1014
978
|
|
|
1015
|
-
def
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
source,
|
|
1021
|
-
output,
|
|
1022
|
-
"member",
|
|
1023
|
-
100,
|
|
1024
|
-
0,
|
|
1025
|
-
_limits_with(member_bytes=64, expanded_bytes=1024),
|
|
1026
|
-
)
|
|
1027
|
-
self.assertIn(
|
|
1028
|
-
"'member' exceeds the 64-byte actual expanded safety limit",
|
|
1029
|
-
str(raised.exception),
|
|
979
|
+
def test_archive_copy_accepts_a_member_larger_than_the_former_cap(self):
|
|
980
|
+
size = _FORMER_MEMBER_CAP_BYTES + 1024 * 1024
|
|
981
|
+
sink = _CountingSink()
|
|
982
|
+
copied = _cli._copy_archive_member(
|
|
983
|
+
_ZeroSource(size), sink, "codebase-memory-mcp", size
|
|
1030
984
|
)
|
|
1031
|
-
|
|
1032
|
-
self.assertEqual(
|
|
1033
|
-
self.assertLessEqual(len(output.getvalue()), 64)
|
|
985
|
+
self.assertEqual(copied, size)
|
|
986
|
+
self.assertEqual(sink.written, size)
|
|
1034
987
|
|
|
1035
988
|
def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
|
|
1036
|
-
source = io.BytesIO(b"x" * 100)
|
|
1037
989
|
with self.assertRaises(SystemExit) as raised:
|
|
1038
|
-
_cli.
|
|
1039
|
-
source,
|
|
1040
|
-
io.BytesIO(),
|
|
1041
|
-
"member",
|
|
1042
|
-
10,
|
|
1043
|
-
0,
|
|
1044
|
-
_limits_with(member_bytes=1024, expanded_bytes=1024),
|
|
1045
|
-
)
|
|
990
|
+
_cli._copy_archive_member(io.BytesIO(b"x" * 100), io.BytesIO(), "member", 10)
|
|
1046
991
|
self.assertIn(
|
|
1047
992
|
"'member' actual size 100 does not match declared size 10",
|
|
1048
993
|
str(raised.exception),
|
|
1049
994
|
)
|
|
1050
995
|
|
|
1051
|
-
def test_archive_copy_rejects_an_aggregate_over_the_actual_limit(self):
|
|
1052
|
-
with self.assertRaises(SystemExit) as raised:
|
|
1053
|
-
_cli._copy_archive_member_within_limits(
|
|
1054
|
-
io.BytesIO(b"abcd"),
|
|
1055
|
-
io.BytesIO(),
|
|
1056
|
-
"member",
|
|
1057
|
-
4,
|
|
1058
|
-
3,
|
|
1059
|
-
_limits_with(member_bytes=10, expanded_bytes=5),
|
|
1060
|
-
)
|
|
1061
|
-
self.assertIn(
|
|
1062
|
-
"5-byte aggregate actual expanded safety limit", str(raised.exception)
|
|
1063
|
-
)
|
|
1064
|
-
|
|
1065
996
|
def test_archive_member_write_removes_the_partial_file_on_failure(self):
|
|
1066
997
|
with tempfile.TemporaryDirectory() as root:
|
|
1067
998
|
target = os.path.join(root, "codebase-memory-mcp")
|
|
1068
999
|
with self.assertRaises(SystemExit):
|
|
1069
|
-
_cli.
|
|
1070
|
-
io.BytesIO(b"x" * 100),
|
|
1071
|
-
target,
|
|
1072
|
-
"codebase-memory-mcp",
|
|
1073
|
-
10,
|
|
1074
|
-
0,
|
|
1075
|
-
_limits_with(member_bytes=1024, expanded_bytes=1024),
|
|
1000
|
+
_cli._write_archive_member(
|
|
1001
|
+
io.BytesIO(b"x" * 100), target, "codebase-memory-mcp", 10
|
|
1076
1002
|
)
|
|
1077
1003
|
self.assertFalse(os.path.exists(target))
|
|
1078
1004
|
|
|
@@ -1088,28 +1014,11 @@ class ArchiveLimitTests(unittest.TestCase):
|
|
|
1088
1014
|
str(destination),
|
|
1089
1015
|
("one", "two", "three"),
|
|
1090
1016
|
("one",),
|
|
1091
|
-
|
|
1017
|
+
max_members=2,
|
|
1092
1018
|
)
|
|
1093
1019
|
self.assertIn("2-member safety limit", str(raised.exception))
|
|
1094
1020
|
|
|
1095
|
-
def
|
|
1096
|
-
with tempfile.TemporaryDirectory() as root:
|
|
1097
|
-
archive = Path(root) / "member.zip"
|
|
1098
|
-
_write_zip(archive, [(_cli._WINDOWS_BINARY_NAME, b"x" * 65)])
|
|
1099
|
-
destination = Path(root) / "extract"
|
|
1100
|
-
destination.mkdir()
|
|
1101
|
-
with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
|
|
1102
|
-
_cli._safe_extract_zip(
|
|
1103
|
-
zf,
|
|
1104
|
-
str(destination),
|
|
1105
|
-
(_cli._WINDOWS_BINARY_NAME,),
|
|
1106
|
-
(_cli._WINDOWS_BINARY_NAME,),
|
|
1107
|
-
limits=_limits_with(member_bytes=64),
|
|
1108
|
-
)
|
|
1109
|
-
self.assertIn("64-byte expanded safety limit", str(raised.exception))
|
|
1110
|
-
self.assertFalse((destination / _cli._WINDOWS_BINARY_NAME).exists())
|
|
1111
|
-
|
|
1112
|
-
def test_windows_zip_within_the_limits_extracts_only_the_executable(self):
|
|
1021
|
+
def test_windows_zip_extracts_only_the_executable(self):
|
|
1113
1022
|
with tempfile.TemporaryDirectory() as root:
|
|
1114
1023
|
archive = Path(root) / "release.zip"
|
|
1115
1024
|
_write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
|
|
@@ -1129,3 +1038,161 @@ class ArchiveLimitTests(unittest.TestCase):
|
|
|
1129
1038
|
)
|
|
1130
1039
|
self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
|
|
1131
1040
|
|
|
1041
|
+
|
|
1042
|
+
@contextlib.contextmanager
|
|
1043
|
+
def _loopback_server(routes):
|
|
1044
|
+
"""Serve `routes` ({path: bytes}) on 127.0.0.1; any other path redirects."""
|
|
1045
|
+
|
|
1046
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
1047
|
+
def do_GET(self):
|
|
1048
|
+
body = routes.get(self.path)
|
|
1049
|
+
if body is None:
|
|
1050
|
+
self.send_response(302)
|
|
1051
|
+
self.send_header("Location", "https://example.invalid/elsewhere")
|
|
1052
|
+
self.send_header("Content-Length", "0")
|
|
1053
|
+
self.end_headers()
|
|
1054
|
+
return
|
|
1055
|
+
self.send_response(200)
|
|
1056
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1057
|
+
self.end_headers()
|
|
1058
|
+
self.wfile.write(body)
|
|
1059
|
+
|
|
1060
|
+
def log_message(self, *args):
|
|
1061
|
+
pass
|
|
1062
|
+
|
|
1063
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
1064
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
1065
|
+
thread.start()
|
|
1066
|
+
try:
|
|
1067
|
+
yield f"http://127.0.0.1:{server.server_address[1]}"
|
|
1068
|
+
finally:
|
|
1069
|
+
server.shutdown()
|
|
1070
|
+
server.server_close()
|
|
1071
|
+
thread.join()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
class DownloadBaseTests(unittest.TestCase):
|
|
1075
|
+
GITHUB_BASE = "https://github.com/DeusData/codebase-memory-mcp/releases/download/v1.2.3"
|
|
1076
|
+
|
|
1077
|
+
def test_unset_or_empty_uses_the_github_release_base(self):
|
|
1078
|
+
self.assertEqual(_cli._download_base("1.2.3", {}), (self.GITHUB_BASE, False))
|
|
1079
|
+
self.assertEqual(
|
|
1080
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": ""}),
|
|
1081
|
+
(self.GITHUB_BASE, False),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def test_https_override_is_accepted(self):
|
|
1085
|
+
for base in (
|
|
1086
|
+
"https://mirror.example/releases/v1",
|
|
1087
|
+
"https://127.0.0.1:8443/fixture",
|
|
1088
|
+
):
|
|
1089
|
+
with self.subTest(base=base):
|
|
1090
|
+
self.assertEqual(
|
|
1091
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
|
1092
|
+
(base, False),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
def test_exact_loopback_http_override_is_accepted(self):
|
|
1096
|
+
for base in (
|
|
1097
|
+
"http://localhost",
|
|
1098
|
+
"http://localhost:8080/fixture",
|
|
1099
|
+
"http://127.0.0.1:41234/standard",
|
|
1100
|
+
"http://[::1]:9/x?y#z",
|
|
1101
|
+
):
|
|
1102
|
+
with self.subTest(base=base):
|
|
1103
|
+
self.assertEqual(
|
|
1104
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
|
1105
|
+
(base, True),
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
def test_non_loopback_http_and_other_schemes_are_refused(self):
|
|
1109
|
+
for base in (
|
|
1110
|
+
"http://example.com/releases",
|
|
1111
|
+
"http://127.0.0.1.example.com/",
|
|
1112
|
+
"http://localhost.example.com:8080/",
|
|
1113
|
+
"http://user@localhost/",
|
|
1114
|
+
"http://127.0.0.2/",
|
|
1115
|
+
"HTTP://localhost/",
|
|
1116
|
+
"file:///tmp/releases",
|
|
1117
|
+
"ftp://localhost/releases",
|
|
1118
|
+
"github.com/DeusData/codebase-memory-mcp",
|
|
1119
|
+
):
|
|
1120
|
+
with self.subTest(base=base), self.assertRaises(SystemExit) as raised:
|
|
1121
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base})
|
|
1122
|
+
self.assertIn(
|
|
1123
|
+
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
|
|
1124
|
+
str(raised.exception),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
def test_https_download_refuses_plain_http(self):
|
|
1128
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1129
|
+
with self.assertRaises(SystemExit):
|
|
1130
|
+
_cli._download_file(
|
|
1131
|
+
"http://127.0.0.1/release.tar.gz", os.path.join(root, "a")
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
def test_loopback_download_refuses_a_non_loopback_url(self):
|
|
1135
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1136
|
+
with self.assertRaises(SystemExit) as raised:
|
|
1137
|
+
_cli._download_file(
|
|
1138
|
+
"http://example.invalid/release.tar.gz",
|
|
1139
|
+
os.path.join(root, "a"),
|
|
1140
|
+
loopback=True,
|
|
1141
|
+
)
|
|
1142
|
+
self.assertIn("refusing non-loopback HTTP URL", str(raised.exception))
|
|
1143
|
+
|
|
1144
|
+
def test_loopback_download_bypasses_proxies_and_refuses_redirects(self):
|
|
1145
|
+
routes = {"/fixture/archive": b"loopback payload"}
|
|
1146
|
+
# A configured proxy that is not listening: using it would fail.
|
|
1147
|
+
proxy_env = {"http_proxy": "http://127.0.0.1:9", "HTTP_PROXY": "http://127.0.0.1:9"}
|
|
1148
|
+
with _loopback_server(routes) as origin, tempfile.TemporaryDirectory() as root, \
|
|
1149
|
+
mock.patch.dict(os.environ, proxy_env):
|
|
1150
|
+
archive = os.path.join(root, "archive")
|
|
1151
|
+
_cli._download_file(f"{origin}/fixture/archive", archive, loopback=True)
|
|
1152
|
+
with open(archive, "rb") as stored:
|
|
1153
|
+
self.assertEqual(stored.read(), b"loopback payload")
|
|
1154
|
+
|
|
1155
|
+
redirected = os.path.join(root, "redirected")
|
|
1156
|
+
with self.assertRaises(RuntimeError) as raised:
|
|
1157
|
+
_cli._download_file(
|
|
1158
|
+
f"{origin}/fixture/checksums.txt", redirected, loopback=True
|
|
1159
|
+
)
|
|
1160
|
+
self.assertIn("refusing redirect from loopback download", str(raised.exception))
|
|
1161
|
+
self.assertFalse(os.path.exists(redirected))
|
|
1162
|
+
|
|
1163
|
+
def test_download_fetches_archive_and_checksums_from_the_override(self):
|
|
1164
|
+
os_name = _cli._os_name()
|
|
1165
|
+
arch = _cli._arch()
|
|
1166
|
+
if os_name == "windows":
|
|
1167
|
+
ext, names = "zip", _cli._WINDOWS_ARCHIVE_NAMES
|
|
1168
|
+
else:
|
|
1169
|
+
ext, names = "tar.gz", _cli._UNIX_ARCHIVE_NAMES
|
|
1170
|
+
portable = "-portable" if os_name == "linux" else ""
|
|
1171
|
+
archive_name = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
|
1172
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1173
|
+
fixture = Path(root) / archive_name
|
|
1174
|
+
if ext == "zip":
|
|
1175
|
+
_write_zip(fixture, _release_entries(names))
|
|
1176
|
+
else:
|
|
1177
|
+
_write_tar_gz(fixture, _release_entries(names))
|
|
1178
|
+
archive_bytes = fixture.read_bytes()
|
|
1179
|
+
digest = hashlib.sha256(archive_bytes).hexdigest()
|
|
1180
|
+
routes = {
|
|
1181
|
+
f"/fixture/{archive_name}": archive_bytes,
|
|
1182
|
+
"/fixture/checksums.txt": f"{digest} {archive_name}\n".encode(),
|
|
1183
|
+
}
|
|
1184
|
+
cache = Path(root) / "cache"
|
|
1185
|
+
with _loopback_server(routes) as origin, mock.patch.dict(
|
|
1186
|
+
os.environ, {"CBM_DOWNLOAD_URL": f"{origin}/fixture"}
|
|
1187
|
+
), mock.patch.object(_cli, "_cache_dir", return_value=cache), \
|
|
1188
|
+
mock.patch.object(_cli, "_verify_candidate"), \
|
|
1189
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1190
|
+
installed = _cli._download("9.9.9")
|
|
1191
|
+
binary_name = (
|
|
1192
|
+
_cli._WINDOWS_BINARY_NAME
|
|
1193
|
+
if os_name == "windows"
|
|
1194
|
+
else "codebase-memory-mcp"
|
|
1195
|
+
)
|
|
1196
|
+
self.assertEqual(
|
|
1197
|
+
installed.read_bytes(), f"payload of {binary_name}".encode()
|
|
1198
|
+
)
|
|
File without changes
|
|
File without changes
|
{codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__init__.py
RENAMED
|
File without changes
|
{codebase_memory_mcp-0.12.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__main__.py
RENAMED
|
File without changes
|