codebase-memory-mcp 0.11.0__tar.gz → 0.12.0__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.11.0 → codebase_memory_mcp-0.12.0}/.gitignore +3 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/PKG-INFO +1 -1
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/pyproject.toml +1 -1
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/requirements-publish.txt +3 -3
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/src/codebase_memory_mcp/_cli.py +206 -24
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/tests/test_cli.py +294 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/README.md +0 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/src/codebase_memory_mcp/__init__.py +0 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/src/codebase_memory_mcp/__main__.py +0 -0
|
@@ -51,6 +51,9 @@ graph-ui/dist/
|
|
|
51
51
|
# Generated by scripts/embed-frontend.sh into the source tree (the linker needs
|
|
52
52
|
# it beside the other UI sources). Removed by scripts/clean.sh; never committed.
|
|
53
53
|
src/ui/embedded_assets.c
|
|
54
|
+
# Raw coverage profiles. A coverage-instrumented binary (make test-runner-cov,
|
|
55
|
+
# cbm-cov) run without LLVM_PROFILE_FILE drops default.profraw in its cwd.
|
|
56
|
+
*.profraw
|
|
54
57
|
|
|
55
58
|
# Generated reports
|
|
56
59
|
BENCHMARK_REPORT.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebase-memory-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
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
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "codebase-memory-mcp"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.12.0"
|
|
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" }
|
|
@@ -354,6 +354,6 @@ secretstorage==3.5.0 \
|
|
|
354
354
|
twine==7.0.0 \
|
|
355
355
|
--hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \
|
|
356
356
|
--hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7
|
|
357
|
-
urllib3==2.
|
|
358
|
-
--hash=sha256:
|
|
359
|
-
--hash=sha256:
|
|
357
|
+
urllib3==2.8.0 \
|
|
358
|
+
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
|
|
359
|
+
--hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Downloads codebase-memory-mcp on first run, then runs its native entry point."""
|
|
2
2
|
|
|
3
|
+
import collections
|
|
3
4
|
import errno
|
|
4
5
|
import hashlib
|
|
5
6
|
import json
|
|
@@ -42,6 +43,22 @@ _MAX_REDIRECTS = 5
|
|
|
42
43
|
_NETWORK_TIMEOUT_SECONDS = 120
|
|
43
44
|
_CANDIDATE_TIMEOUT_SECONDS = 15
|
|
44
45
|
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
|
|
46
|
+
# Release archive resource limits. Keep in sync with the Go wrapper
|
|
47
|
+
# (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper (install.js).
|
|
48
|
+
_MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024
|
|
49
|
+
_MAX_ARCHIVE_MEMBERS = 64
|
|
50
|
+
_MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024
|
|
51
|
+
_MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024
|
|
52
|
+
_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
|
+
)
|
|
45
62
|
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
|
46
63
|
_RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
|
|
47
64
|
_RUNTIME_LOCK_WAIT_SECONDS = 45
|
|
@@ -113,27 +130,50 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
|
|
113
130
|
|
|
114
131
|
with response:
|
|
115
132
|
_validate_url_scheme(response.geturl())
|
|
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
|
+
)
|
|
116
142
|
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
|
117
143
|
total = 0
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
try:
|
|
145
|
+
with open(dest, "wb") as out:
|
|
146
|
+
while True:
|
|
147
|
+
if time.monotonic() >= deadline:
|
|
148
|
+
raise TimeoutError(
|
|
149
|
+
f"download hop timed out: {current_url}"
|
|
150
|
+
)
|
|
151
|
+
chunk = response.read(65536)
|
|
152
|
+
if not chunk:
|
|
153
|
+
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
|
+
out.write(chunk)
|
|
161
|
+
except BaseException:
|
|
162
|
+
# Never leave a partial download behind for a later step to
|
|
163
|
+
# mistake for the complete asset.
|
|
164
|
+
_remove_quietly(dest)
|
|
165
|
+
raise
|
|
133
166
|
|
|
134
167
|
raise RuntimeError("too many redirects")
|
|
135
168
|
|
|
136
169
|
|
|
170
|
+
def _remove_quietly(path) -> None:
|
|
171
|
+
try:
|
|
172
|
+
os.unlink(path)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
137
177
|
def _verify_candidate(path: Path) -> None:
|
|
138
178
|
"""Require a staged native candidate to execute successfully."""
|
|
139
179
|
try:
|
|
@@ -196,33 +236,157 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
|
|
|
196
236
|
)
|
|
197
237
|
|
|
198
238
|
|
|
239
|
+
def _validate_archive_limits(limits) -> None:
|
|
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):
|
|
252
|
+
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
|
+
|
|
259
|
+
|
|
260
|
+
def _require_member_count_within_limit(count: int, limits) -> None:
|
|
261
|
+
if count > limits.members:
|
|
262
|
+
sys.exit(
|
|
263
|
+
f"codebase-memory-mcp: archive exceeds the {limits.members}-member "
|
|
264
|
+
f"safety limit"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _account_declared_archive_member(
|
|
269
|
+
name: str, size: int, declared_expanded: int, limits
|
|
270
|
+
) -> int:
|
|
271
|
+
"""Check one member's declared size; return the new declared total."""
|
|
272
|
+
if size < 0:
|
|
273
|
+
sys.exit(
|
|
274
|
+
f"codebase-memory-mcp: archive member has a negative declared "
|
|
275
|
+
f"size: {name!r}"
|
|
276
|
+
)
|
|
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
|
+
|
|
289
|
+
|
|
290
|
+
def _copy_archive_member_within_limits(
|
|
291
|
+
source, output, name: str, declared_size: int, actual_expanded: int, limits
|
|
292
|
+
) -> int:
|
|
293
|
+
"""Copy a member while counting bytes; return the new actual total.
|
|
294
|
+
|
|
295
|
+
Reads stop one byte past the smaller of the member and remaining aggregate
|
|
296
|
+
limits, so a member that yields more than it declares can never fill the
|
|
297
|
+
disk. The caller removes the target on failure.
|
|
298
|
+
"""
|
|
299
|
+
remaining = limits.expanded_bytes - actual_expanded
|
|
300
|
+
copy_limit = min(limits.member_bytes, remaining)
|
|
301
|
+
copied = 0
|
|
302
|
+
while True:
|
|
303
|
+
wanted = min(_ARCHIVE_COPY_CHUNK_BYTES, copy_limit + 1 - copied)
|
|
304
|
+
if wanted <= 0:
|
|
305
|
+
break
|
|
306
|
+
chunk = source.read(wanted)
|
|
307
|
+
if not chunk:
|
|
308
|
+
break
|
|
309
|
+
copied += len(chunk)
|
|
310
|
+
if copied > copy_limit:
|
|
311
|
+
break
|
|
312
|
+
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
|
+
if copied != declared_size:
|
|
324
|
+
sys.exit(
|
|
325
|
+
f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
|
|
326
|
+
f"does not match declared size {declared_size}"
|
|
327
|
+
)
|
|
328
|
+
return actual_expanded + copied
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _write_archive_member_within_limits(
|
|
332
|
+
source, target: str, name: str, declared_size: int, actual_expanded: int, limits
|
|
333
|
+
) -> int:
|
|
334
|
+
try:
|
|
335
|
+
with open(target, "xb") as output:
|
|
336
|
+
return _copy_archive_member_within_limits(
|
|
337
|
+
source, output, name, declared_size, actual_expanded, limits
|
|
338
|
+
)
|
|
339
|
+
except BaseException:
|
|
340
|
+
_remove_quietly(target)
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
|
|
199
344
|
def _safe_extract_tar(
|
|
200
|
-
tf,
|
|
345
|
+
tf,
|
|
346
|
+
dest: str,
|
|
347
|
+
archive_names=(),
|
|
348
|
+
extract_names=(),
|
|
349
|
+
ui: bool = False,
|
|
350
|
+
limits=_DEFAULT_ARCHIVE_LIMITS,
|
|
201
351
|
):
|
|
202
352
|
"""Validate a tar namespace, then extract only its authenticated runtime set."""
|
|
353
|
+
_validate_archive_limits(limits)
|
|
203
354
|
members = tf.getmembers()
|
|
355
|
+
_require_member_count_within_limit(len(members), limits)
|
|
356
|
+
declared_expanded = 0
|
|
204
357
|
for member in members:
|
|
205
358
|
if not member.isfile():
|
|
206
359
|
sys.exit(
|
|
207
360
|
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
208
361
|
f"(not a regular root file: {member.name!r})"
|
|
209
362
|
)
|
|
363
|
+
declared_expanded = _account_declared_archive_member(
|
|
364
|
+
member.name, member.size, declared_expanded, limits
|
|
365
|
+
)
|
|
210
366
|
_validate_archive_names([member.name for member in members], archive_names)
|
|
211
367
|
runtime_names = list(extract_names)
|
|
212
368
|
by_name = {member.name: member for member in members}
|
|
213
369
|
dest_abs = os.path.abspath(dest)
|
|
370
|
+
actual_expanded = 0
|
|
214
371
|
for name in runtime_names:
|
|
215
372
|
source = tf.extractfile(by_name[name])
|
|
216
373
|
if source is None:
|
|
217
374
|
sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
|
|
218
375
|
target = os.path.join(dest_abs, name)
|
|
219
|
-
with source
|
|
220
|
-
|
|
376
|
+
with source:
|
|
377
|
+
actual_expanded = _write_archive_member_within_limits(
|
|
378
|
+
source, target, name, by_name[name].size, actual_expanded, limits
|
|
379
|
+
)
|
|
221
380
|
return tuple(runtime_names)
|
|
222
381
|
|
|
223
382
|
|
|
224
383
|
def _safe_extract_zip(
|
|
225
|
-
zf,
|
|
384
|
+
zf,
|
|
385
|
+
dest: str,
|
|
386
|
+
archive_names=(),
|
|
387
|
+
extract_names=(),
|
|
388
|
+
ui: bool = False,
|
|
389
|
+
limits=_DEFAULT_ARCHIVE_LIMITS,
|
|
226
390
|
):
|
|
227
391
|
"""Extract a zipfile after validating its Windows-style namespace.
|
|
228
392
|
|
|
@@ -230,9 +394,16 @@ def _safe_extract_zip(
|
|
|
230
394
|
members before extraction prevents archive order from selecting the binary
|
|
231
395
|
that a portable package-manager shim eventually executes.
|
|
232
396
|
"""
|
|
397
|
+
_validate_archive_limits(limits)
|
|
233
398
|
dest_abs = os.path.abspath(dest)
|
|
234
399
|
names = []
|
|
235
|
-
|
|
400
|
+
infos = zf.infolist()
|
|
401
|
+
_require_member_count_within_limit(len(infos), limits)
|
|
402
|
+
declared_expanded = 0
|
|
403
|
+
for info in infos:
|
|
404
|
+
declared_expanded = _account_declared_archive_member(
|
|
405
|
+
info.filename, info.file_size, declared_expanded, limits
|
|
406
|
+
)
|
|
236
407
|
raw_name = info.filename
|
|
237
408
|
name = raw_name.replace("\\", "/")
|
|
238
409
|
segments_name = name[:-1] if name.endswith("/") else name
|
|
@@ -266,10 +437,18 @@ def _safe_extract_zip(
|
|
|
266
437
|
# Extract only the validated runtime files. This avoids relying on
|
|
267
438
|
# platform-specific zip path rewriting and always creates regular files.
|
|
268
439
|
runtime_names = list(extract_names)
|
|
440
|
+
actual_expanded = 0
|
|
269
441
|
for name in runtime_names:
|
|
270
442
|
target = os.path.join(dest_abs, name)
|
|
271
|
-
with zf.open(name) as source
|
|
272
|
-
|
|
443
|
+
with zf.open(name) as source:
|
|
444
|
+
actual_expanded = _write_archive_member_within_limits(
|
|
445
|
+
source,
|
|
446
|
+
target,
|
|
447
|
+
name,
|
|
448
|
+
zf.getinfo(name).file_size,
|
|
449
|
+
actual_expanded,
|
|
450
|
+
limits,
|
|
451
|
+
)
|
|
273
452
|
return tuple(runtime_names)
|
|
274
453
|
|
|
275
454
|
|
|
@@ -338,7 +517,7 @@ def _version() -> str:
|
|
|
338
517
|
from importlib.metadata import version
|
|
339
518
|
return version("codebase-memory-mcp")
|
|
340
519
|
except Exception:
|
|
341
|
-
return "0.
|
|
520
|
+
return "0.11.0"
|
|
342
521
|
|
|
343
522
|
|
|
344
523
|
def _os_name() -> str:
|
|
@@ -1179,7 +1358,7 @@ def _download(version: str) -> Path:
|
|
|
1179
1358
|
with tempfile.TemporaryDirectory() as tmp:
|
|
1180
1359
|
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
|
1181
1360
|
try:
|
|
1182
|
-
_download_https(url, tmp_archive)
|
|
1361
|
+
_download_https(url, tmp_archive, _MAX_RELEASE_ARCHIVE_BYTES)
|
|
1183
1362
|
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
|
1184
1363
|
sys.exit(
|
|
1185
1364
|
f"codebase-memory-mcp: download failed ({e})\n"
|
|
@@ -1188,6 +1367,9 @@ def _download(version: str) -> Path:
|
|
|
1188
1367
|
)
|
|
1189
1368
|
|
|
1190
1369
|
_verify_checksum(tmp_archive, archive, version)
|
|
1370
|
+
_require_compressed_archive_within_limit(
|
|
1371
|
+
tmp_archive, _DEFAULT_ARCHIVE_LIMITS
|
|
1372
|
+
)
|
|
1191
1373
|
|
|
1192
1374
|
bin_name = (
|
|
1193
1375
|
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import errno
|
|
2
2
|
import hashlib
|
|
3
|
+
import io
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import stat
|
|
@@ -835,3 +836,296 @@ _cli._publish_runtime_set(
|
|
|
835
836
|
False,
|
|
836
837
|
)
|
|
837
838
|
|
|
839
|
+
|
|
840
|
+
class _FakeHttpsResponse:
|
|
841
|
+
"""Minimal stand-in for the opener's response: headers, geturl, read."""
|
|
842
|
+
|
|
843
|
+
def __init__(self, body: bytes, headers=None):
|
|
844
|
+
self._body = body
|
|
845
|
+
self._offset = 0
|
|
846
|
+
self.headers = dict(headers or {})
|
|
847
|
+
|
|
848
|
+
def __enter__(self):
|
|
849
|
+
return self
|
|
850
|
+
|
|
851
|
+
def __exit__(self, *exc_info):
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
def geturl(self):
|
|
855
|
+
return "https://example.invalid/release.tar.gz"
|
|
856
|
+
|
|
857
|
+
def read(self, size=-1):
|
|
858
|
+
if size is None or size < 0:
|
|
859
|
+
size = len(self._body) - self._offset
|
|
860
|
+
chunk = self._body[self._offset : self._offset + size]
|
|
861
|
+
self._offset += len(chunk)
|
|
862
|
+
return chunk
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _limits_with(**overrides):
|
|
866
|
+
return _cli._DEFAULT_ARCHIVE_LIMITS._replace(**overrides)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _write_tar_gz(archive, entries):
|
|
870
|
+
with tarfile.open(archive, "w:gz") as tf:
|
|
871
|
+
for name, data in entries:
|
|
872
|
+
info = tarfile.TarInfo(name)
|
|
873
|
+
info.size = len(data)
|
|
874
|
+
tf.addfile(info, io.BytesIO(data))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _write_zip(archive, entries):
|
|
878
|
+
with zipfile.ZipFile(archive, "w") as zf:
|
|
879
|
+
for name, data in entries:
|
|
880
|
+
zf.writestr(name, data)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _release_entries(names):
|
|
884
|
+
return [(name, f"payload of {name}".encode()) for name in names]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class ArchiveLimitTests(unittest.TestCase):
|
|
888
|
+
URL = "https://example.invalid/release.tar.gz"
|
|
889
|
+
|
|
890
|
+
def _download(self, response, destination, max_bytes):
|
|
891
|
+
with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
|
|
892
|
+
_cli._download_https(self.URL, destination, max_bytes)
|
|
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))
|
|
902
|
+
|
|
903
|
+
def test_download_rejects_a_body_over_the_limit_and_removes_the_partial_file(
|
|
904
|
+
self,
|
|
905
|
+
):
|
|
906
|
+
with tempfile.TemporaryDirectory() as root:
|
|
907
|
+
destination = os.path.join(root, "release.tar.gz")
|
|
908
|
+
response = _FakeHttpsResponse(b"x" * 1024)
|
|
909
|
+
with self.assertRaises(RuntimeError) as raised:
|
|
910
|
+
self._download(response, destination, 8)
|
|
911
|
+
self.assertIn("8-byte safety limit", str(raised.exception))
|
|
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)
|
|
919
|
+
with open(destination, "rb") as stored:
|
|
920
|
+
self.assertEqual(stored.read(), b"payload")
|
|
921
|
+
|
|
922
|
+
def test_download_counts_bytes_when_the_declared_length_is_not_a_number(self):
|
|
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))
|
|
933
|
+
with tempfile.TemporaryDirectory() as root:
|
|
934
|
+
destination = os.path.join(root, "release.tar.gz")
|
|
935
|
+
response = _FakeHttpsResponse(b"payload", {"Content-Length": "²"})
|
|
936
|
+
self._download(response, destination, 1024)
|
|
937
|
+
with open(destination, "rb") as stored:
|
|
938
|
+
self.assertEqual(stored.read(), b"payload")
|
|
939
|
+
|
|
940
|
+
def test_unix_tar_rejects_more_members_than_the_limit(self):
|
|
941
|
+
with tempfile.TemporaryDirectory() as root:
|
|
942
|
+
archive = Path(root) / "members.tar.gz"
|
|
943
|
+
_write_tar_gz(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
|
|
944
|
+
destination = Path(root) / "extract"
|
|
945
|
+
destination.mkdir()
|
|
946
|
+
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
|
947
|
+
_cli._safe_extract_tar(
|
|
948
|
+
tf,
|
|
949
|
+
str(destination),
|
|
950
|
+
("one", "two", "three"),
|
|
951
|
+
("one",),
|
|
952
|
+
limits=_limits_with(members=2),
|
|
953
|
+
)
|
|
954
|
+
self.assertIn("2-member safety limit", str(raised.exception))
|
|
955
|
+
self.assertFalse((destination / "one").exists())
|
|
956
|
+
|
|
957
|
+
def test_unix_tar_rejects_a_member_declared_over_the_limit(self):
|
|
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):
|
|
996
|
+
with tempfile.TemporaryDirectory() as root:
|
|
997
|
+
archive = Path(root) / "release.tar.gz"
|
|
998
|
+
_write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
|
|
999
|
+
destination = Path(root) / "extract"
|
|
1000
|
+
destination.mkdir()
|
|
1001
|
+
with tarfile.open(archive) as tf:
|
|
1002
|
+
extracted = _cli._safe_extract_tar(
|
|
1003
|
+
tf,
|
|
1004
|
+
str(destination),
|
|
1005
|
+
_cli._UNIX_ARCHIVE_NAMES,
|
|
1006
|
+
("codebase-memory-mcp",),
|
|
1007
|
+
)
|
|
1008
|
+
self.assertEqual(extracted, ("codebase-memory-mcp",))
|
|
1009
|
+
self.assertEqual(
|
|
1010
|
+
(destination / "codebase-memory-mcp").read_bytes(),
|
|
1011
|
+
b"payload of codebase-memory-mcp",
|
|
1012
|
+
)
|
|
1013
|
+
self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
|
|
1014
|
+
|
|
1015
|
+
def test_archive_copy_stops_a_member_that_yields_more_than_the_limit(self):
|
|
1016
|
+
source = io.BytesIO(b"x" * 100)
|
|
1017
|
+
output = io.BytesIO()
|
|
1018
|
+
with self.assertRaises(SystemExit) as raised:
|
|
1019
|
+
_cli._copy_archive_member_within_limits(
|
|
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),
|
|
1030
|
+
)
|
|
1031
|
+
# Reading stopped one byte past the limit; nothing beyond it was kept.
|
|
1032
|
+
self.assertEqual(source.tell(), 65)
|
|
1033
|
+
self.assertLessEqual(len(output.getvalue()), 64)
|
|
1034
|
+
|
|
1035
|
+
def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
|
|
1036
|
+
source = io.BytesIO(b"x" * 100)
|
|
1037
|
+
with self.assertRaises(SystemExit) as raised:
|
|
1038
|
+
_cli._copy_archive_member_within_limits(
|
|
1039
|
+
source,
|
|
1040
|
+
io.BytesIO(),
|
|
1041
|
+
"member",
|
|
1042
|
+
10,
|
|
1043
|
+
0,
|
|
1044
|
+
_limits_with(member_bytes=1024, expanded_bytes=1024),
|
|
1045
|
+
)
|
|
1046
|
+
self.assertIn(
|
|
1047
|
+
"'member' actual size 100 does not match declared size 10",
|
|
1048
|
+
str(raised.exception),
|
|
1049
|
+
)
|
|
1050
|
+
|
|
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
|
+
def test_archive_member_write_removes_the_partial_file_on_failure(self):
|
|
1066
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1067
|
+
target = os.path.join(root, "codebase-memory-mcp")
|
|
1068
|
+
with self.assertRaises(SystemExit):
|
|
1069
|
+
_cli._write_archive_member_within_limits(
|
|
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),
|
|
1076
|
+
)
|
|
1077
|
+
self.assertFalse(os.path.exists(target))
|
|
1078
|
+
|
|
1079
|
+
def test_windows_zip_rejects_more_members_than_the_limit(self):
|
|
1080
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1081
|
+
archive = Path(root) / "members.zip"
|
|
1082
|
+
_write_zip(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
|
|
1083
|
+
destination = Path(root) / "extract"
|
|
1084
|
+
destination.mkdir()
|
|
1085
|
+
with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
|
|
1086
|
+
_cli._safe_extract_zip(
|
|
1087
|
+
zf,
|
|
1088
|
+
str(destination),
|
|
1089
|
+
("one", "two", "three"),
|
|
1090
|
+
("one",),
|
|
1091
|
+
limits=_limits_with(members=2),
|
|
1092
|
+
)
|
|
1093
|
+
self.assertIn("2-member safety limit", str(raised.exception))
|
|
1094
|
+
|
|
1095
|
+
def test_windows_zip_rejects_a_member_declared_over_the_limit(self):
|
|
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):
|
|
1113
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1114
|
+
archive = Path(root) / "release.zip"
|
|
1115
|
+
_write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
|
|
1116
|
+
destination = Path(root) / "extract"
|
|
1117
|
+
destination.mkdir()
|
|
1118
|
+
with zipfile.ZipFile(archive) as zf:
|
|
1119
|
+
extracted = _cli._safe_extract_zip(
|
|
1120
|
+
zf,
|
|
1121
|
+
str(destination),
|
|
1122
|
+
_cli._WINDOWS_ARCHIVE_NAMES,
|
|
1123
|
+
(_cli._WINDOWS_BINARY_NAME,),
|
|
1124
|
+
)
|
|
1125
|
+
self.assertEqual(extracted, (_cli._WINDOWS_BINARY_NAME,))
|
|
1126
|
+
self.assertEqual(
|
|
1127
|
+
(destination / _cli._WINDOWS_BINARY_NAME).read_bytes(),
|
|
1128
|
+
b"payload of codebase-memory-mcp.exe",
|
|
1129
|
+
)
|
|
1130
|
+
self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
|
|
1131
|
+
|
|
File without changes
|
{codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/src/codebase_memory_mcp/__init__.py
RENAMED
|
File without changes
|
{codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.0}/src/codebase_memory_mcp/__main__.py
RENAMED
|
File without changes
|