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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-memory-mcp
3
- Version: 0.12.0
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.0"
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
- _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
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 _download_https for explicit per-hop validation."""
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 _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
107
- """Download through a bounded HTTPS-only opener into dest."""
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
- _validate_url_scheme(current_url)
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 = _HTTPS_OPENER.open(
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
- _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
- )
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 _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):
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, limits) -> None:
261
- if count > limits.members:
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 {limits.members}-member "
270
+ f"codebase-memory-mcp: archive exceeds the {max_members}-member "
264
271
  f"safety limit"
265
272
  )
266
273
 
267
274
 
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."""
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 _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.
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
- 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.
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
- wanted = min(_ARCHIVE_COPY_CHUNK_BYTES, copy_limit + 1 - copied)
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 actual_expanded + copied
300
+ return copied
329
301
 
330
302
 
331
- def _write_archive_member_within_limits(
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 _copy_archive_member_within_limits(
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
- limits=_DEFAULT_ARCHIVE_LIMITS,
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), limits)
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
- declared_expanded = _account_declared_archive_member(
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
- actual_expanded = _write_archive_member_within_limits(
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
- limits=_DEFAULT_ARCHIVE_LIMITS,
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), limits)
402
- declared_expanded = 0
361
+ _require_member_count_within_limit(len(infos), max_members)
403
362
  for info in infos:
404
- declared_expanded = _account_declared_archive_member(
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
- 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
- )
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(archive_path: str, archive_name: str, version: str) -> None:
456
- """Verify SHA256 checksum against checksums.txt from the release."""
457
- url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
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
- _validate_url_scheme(url)
411
+ _validate_download_url(url, loopback)
461
412
  with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
462
413
  tmp_path = tmp.name
463
- _download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
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.11.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
- url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
1348
- _validate_url_scheme(url)
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
- _download_https(url, tmp_archive, _MAX_RELEASE_ARCHIVE_BYTES)
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, version)
1370
- _require_compressed_archive_within_limit(
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
- def _limits_with(**overrides):
866
- return _cli._DEFAULT_ARCHIVE_LIMITS._replace(**overrides)
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 ArchiveLimitTests(unittest.TestCase):
915
+ class ArchiveExtractionTests(unittest.TestCase):
888
916
  URL = "https://example.invalid/release.tar.gz"
889
917
 
890
- def _download(self, response, destination, max_bytes):
918
+ def _download(self, response, destination):
891
919
  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))
920
+ _cli._download_file(self.URL, destination)
902
921
 
903
- def test_download_rejects_a_body_over_the_limit_and_removes_the_partial_file(
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(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)
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 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))
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"payload", {"Content-Length": "²"})
936
- self._download(response, destination, 1024)
937
- with open(destination, "rb") as stored:
938
- self.assertEqual(stored.read(), b"payload")
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
- limits=_limits_with(members=2),
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 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):
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 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),
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
- # 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)
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._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
- )
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._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),
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
- limits=_limits_with(members=2),
1017
+ max_members=2,
1092
1018
  )
1093
1019
  self.assertIn("2-member safety limit", str(raised.exception))
1094
1020
 
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):
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
+ )