codebase-memory-mcp 0.11.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.
@@ -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.11.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.11.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" }
@@ -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.7.0 \
358
- --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
359
- --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
357
+ urllib3==2.8.0 \
358
+ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
359
+ --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
@@ -38,10 +38,19 @@ _WINDOWS_ARCHIVE_NAMES = (
38
38
  # file://, ftp://, and custom schemes — a redirect or tainted URL source
39
39
  # could otherwise turn a download into an arbitrary-local-file read.
40
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"})
41
47
  _MAX_REDIRECTS = 5
42
48
  _NETWORK_TIMEOUT_SECONDS = 120
43
49
  _CANDIDATE_TIMEOUT_SECONDS = 15
44
- _MAX_CHECKSUM_MANIFEST_BYTES = 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.
52
+ _MAX_ARCHIVE_MEMBERS = 64
53
+ _ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024
45
54
  _REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
46
55
  _RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
47
56
  _RUNTIME_LOCK_WAIT_SECONDS = 45
@@ -62,13 +71,50 @@ _runtime_lock_claim_observer = None
62
71
 
63
72
 
64
73
  class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
65
- """Expose redirects to _download_https for explicit per-hop validation."""
74
+ """Expose redirects to _download_file for explicit per-hop validation."""
66
75
 
67
76
  def redirect_request(self, req, fp, code, msg, headers, newurl):
68
77
  return None
69
78
 
70
79
 
71
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}")
72
118
 
73
119
 
74
120
  def _validate_url_scheme(url: str) -> None:
@@ -86,23 +132,33 @@ def _validate_url_scheme(url: str) -> None:
86
132
  )
87
133
 
88
134
 
89
- def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
90
- """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
91
145
  current_url = url
92
146
  for redirect_count in range(_MAX_REDIRECTS + 1):
93
- _validate_url_scheme(current_url)
147
+ _validate_download_url(current_url, loopback)
94
148
  request = urllib.request.Request(
95
149
  current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
96
150
  )
97
151
  try:
98
- response = _HTTPS_OPENER.open(
99
- request, timeout=_NETWORK_TIMEOUT_SECONDS
100
- )
152
+ response = opener.open(request, timeout=_NETWORK_TIMEOUT_SECONDS)
101
153
  except urllib.error.HTTPError as exc:
102
154
  if exc.code not in _REDIRECT_CODES:
103
155
  raise
104
156
  location = exc.headers.get("Location")
105
157
  exc.close()
158
+ if loopback:
159
+ raise RuntimeError(
160
+ f"refusing redirect from loopback download: {current_url}"
161
+ )
106
162
  if not location:
107
163
  raise RuntimeError(f"redirect has no Location: {current_url}")
108
164
  if redirect_count == _MAX_REDIRECTS:
@@ -112,28 +168,35 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
112
168
  continue
113
169
 
114
170
  with response:
115
- _validate_url_scheme(response.geturl())
171
+ _validate_download_url(response.geturl(), loopback)
116
172
  deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
117
- total = 0
118
- with open(dest, "wb") as out:
119
- while True:
120
- if time.monotonic() >= deadline:
121
- raise TimeoutError(
122
- f"download hop timed out: {current_url}"
123
- )
124
- chunk = response.read(65536)
125
- if not chunk:
126
- return
127
- total += len(chunk)
128
- if max_bytes and total > max_bytes:
129
- raise RuntimeError(
130
- f"download exceeds the {max_bytes}-byte safety limit"
131
- )
132
- out.write(chunk)
173
+ try:
174
+ with open(dest, "wb") as out:
175
+ while True:
176
+ if time.monotonic() >= deadline:
177
+ raise TimeoutError(
178
+ f"download hop timed out: {current_url}"
179
+ )
180
+ chunk = response.read(65536)
181
+ if not chunk:
182
+ return
183
+ out.write(chunk)
184
+ except BaseException:
185
+ # Never leave a partial download behind for a later step to
186
+ # mistake for the complete asset.
187
+ _remove_quietly(dest)
188
+ raise
133
189
 
134
190
  raise RuntimeError("too many redirects")
135
191
 
136
192
 
193
+ def _remove_quietly(path) -> None:
194
+ try:
195
+ os.unlink(path)
196
+ except OSError:
197
+ pass
198
+
199
+
137
200
  def _verify_candidate(path: Path) -> None:
138
201
  """Require a staged native candidate to execute successfully."""
139
202
  try:
@@ -196,17 +259,74 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
196
259
  )
197
260
 
198
261
 
262
+ def _require_regular_archive(archive_path: str) -> None:
263
+ if not stat.S_ISREG(os.stat(archive_path).st_mode):
264
+ sys.exit("codebase-memory-mcp: release archive is not a regular file")
265
+
266
+
267
+ def _require_member_count_within_limit(count: int, max_members: int) -> None:
268
+ if count > max_members:
269
+ sys.exit(
270
+ f"codebase-memory-mcp: archive exceeds the {max_members}-member "
271
+ f"safety limit"
272
+ )
273
+
274
+
275
+ def _require_declared_size(name: str, size: int) -> None:
276
+ if size < 0:
277
+ sys.exit(
278
+ f"codebase-memory-mcp: archive member has a negative declared "
279
+ f"size: {name!r}"
280
+ )
281
+
282
+
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.
285
+
286
+ The caller removes the target on failure.
287
+ """
288
+ copied = 0
289
+ while True:
290
+ chunk = source.read(_ARCHIVE_COPY_CHUNK_BYTES)
291
+ if not chunk:
292
+ break
293
+ copied += len(chunk)
294
+ output.write(chunk)
295
+ if copied != declared_size:
296
+ sys.exit(
297
+ f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
298
+ f"does not match declared size {declared_size}"
299
+ )
300
+ return copied
301
+
302
+
303
+ def _write_archive_member(source, target: str, name: str, declared_size: int) -> int:
304
+ try:
305
+ with open(target, "xb") as output:
306
+ return _copy_archive_member(source, output, name, declared_size)
307
+ except BaseException:
308
+ _remove_quietly(target)
309
+ raise
310
+
311
+
199
312
  def _safe_extract_tar(
200
- tf, dest: str, archive_names=(), extract_names=(), ui: bool = False
313
+ tf,
314
+ dest: str,
315
+ archive_names=(),
316
+ extract_names=(),
317
+ ui: bool = False,
318
+ max_members: int = _MAX_ARCHIVE_MEMBERS,
201
319
  ):
202
320
  """Validate a tar namespace, then extract only its authenticated runtime set."""
203
321
  members = tf.getmembers()
322
+ _require_member_count_within_limit(len(members), max_members)
204
323
  for member in members:
205
324
  if not member.isfile():
206
325
  sys.exit(
207
326
  f"codebase-memory-mcp: refusing unsafe tar entry "
208
327
  f"(not a regular root file: {member.name!r})"
209
328
  )
329
+ _require_declared_size(member.name, member.size)
210
330
  _validate_archive_names([member.name for member in members], archive_names)
211
331
  runtime_names = list(extract_names)
212
332
  by_name = {member.name: member for member in members}
@@ -216,13 +336,18 @@ def _safe_extract_tar(
216
336
  if source is None:
217
337
  sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
218
338
  target = os.path.join(dest_abs, name)
219
- with source, open(target, "xb") as output:
220
- shutil.copyfileobj(source, output)
339
+ with source:
340
+ _write_archive_member(source, target, name, by_name[name].size)
221
341
  return tuple(runtime_names)
222
342
 
223
343
 
224
344
  def _safe_extract_zip(
225
- zf, dest: str, archive_names=(), extract_names=(), ui: bool = False
345
+ zf,
346
+ dest: str,
347
+ archive_names=(),
348
+ extract_names=(),
349
+ ui: bool = False,
350
+ max_members: int = _MAX_ARCHIVE_MEMBERS,
226
351
  ):
227
352
  """Extract a zipfile after validating its Windows-style namespace.
228
353
 
@@ -232,7 +357,10 @@ def _safe_extract_zip(
232
357
  """
233
358
  dest_abs = os.path.abspath(dest)
234
359
  names = []
235
- for info in zf.infolist():
360
+ infos = zf.infolist()
361
+ _require_member_count_within_limit(len(infos), max_members)
362
+ for info in infos:
363
+ _require_declared_size(info.filename, info.file_size)
236
364
  raw_name = info.filename
237
365
  name = raw_name.replace("\\", "/")
238
366
  segments_name = name[:-1] if name.endswith("/") else name
@@ -268,20 +396,22 @@ def _safe_extract_zip(
268
396
  runtime_names = list(extract_names)
269
397
  for name in runtime_names:
270
398
  target = os.path.join(dest_abs, name)
271
- with zf.open(name) as source, open(target, "xb") as output:
272
- shutil.copyfileobj(source, output)
399
+ with zf.open(name) as source:
400
+ _write_archive_member(source, target, name, zf.getinfo(name).file_size)
273
401
  return tuple(runtime_names)
274
402
 
275
403
 
276
- def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
277
- """Verify SHA256 checksum against checksums.txt from the release."""
278
- 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"
279
409
  tmp_path = None
280
410
  try:
281
- _validate_url_scheme(url)
411
+ _validate_download_url(url, loopback)
282
412
  with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
283
413
  tmp_path = tmp.name
284
- _download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
414
+ _download_file(url, tmp_path, loopback)
285
415
  expected = None
286
416
  with open(tmp_path, encoding="utf-8") as f:
287
417
  for line in f:
@@ -338,7 +468,7 @@ def _version() -> str:
338
468
  from importlib.metadata import version
339
469
  return version("codebase-memory-mcp")
340
470
  except Exception:
341
- return "0.10.8"
471
+ return "0.12.0"
342
472
 
343
473
 
344
474
  def _os_name() -> str:
@@ -1165,8 +1295,9 @@ def _download(version: str) -> Path:
1165
1295
  # have no such variant. Keep in sync with install.sh / install.js / cli.c.
1166
1296
  portable = "-portable" if os_name == "linux" else ""
1167
1297
  archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
1168
- url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
1169
- _validate_url_scheme(url)
1298
+ base, loopback = _download_base(version)
1299
+ url = f"{base}/{archive}"
1300
+ _validate_download_url(url, loopback)
1170
1301
 
1171
1302
  dest = _bin_path(version)
1172
1303
  dest.parent.mkdir(parents=True, exist_ok=True)
@@ -1179,7 +1310,7 @@ def _download(version: str) -> Path:
1179
1310
  with tempfile.TemporaryDirectory() as tmp:
1180
1311
  tmp_archive = os.path.join(tmp, f"cbm.{ext}")
1181
1312
  try:
1182
- _download_https(url, tmp_archive)
1313
+ _download_file(url, tmp_archive, loopback)
1183
1314
  except (OSError, RuntimeError, urllib.error.URLError) as e:
1184
1315
  sys.exit(
1185
1316
  f"codebase-memory-mcp: download failed ({e})\n"
@@ -1187,7 +1318,8 @@ def _download(version: str) -> Path:
1187
1318
  f"See https://github.com/{REPO}/releases for available versions."
1188
1319
  )
1189
1320
 
1190
- _verify_checksum(tmp_archive, archive, version)
1321
+ _verify_checksum(tmp_archive, archive, base, loopback)
1322
+ _require_regular_archive(tmp_archive)
1191
1323
 
1192
1324
  bin_name = (
1193
1325
  _WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
@@ -1,5 +1,8 @@
1
+ import contextlib
1
2
  import errno
2
3
  import hashlib
4
+ import http.server
5
+ import io
3
6
  import json
4
7
  import os
5
8
  import stat
@@ -835,3 +838,361 @@ _cli._publish_runtime_set(
835
838
  False,
836
839
  )
837
840
 
841
+
842
+ class _FakeHttpsResponse:
843
+ """Minimal stand-in for the opener's response: headers, geturl, read."""
844
+
845
+ def __init__(self, body: bytes, headers=None, fail_after_first_chunk=False):
846
+ self._body = body
847
+ self._offset = 0
848
+ self._fail_after_first_chunk = fail_after_first_chunk
849
+ self.headers = dict(headers or {})
850
+
851
+ def __enter__(self):
852
+ return self
853
+
854
+ def __exit__(self, *exc_info):
855
+ return False
856
+
857
+ def geturl(self):
858
+ return "https://example.invalid/release.tar.gz"
859
+
860
+ def read(self, size=-1):
861
+ if self._fail_after_first_chunk and self._offset:
862
+ raise OSError("connection reset")
863
+ if size is None or size < 0:
864
+ size = len(self._body) - self._offset
865
+ chunk = self._body[self._offset : self._offset + size]
866
+ self._offset += len(chunk)
867
+ return chunk
868
+
869
+
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
895
+
896
+
897
+ def _write_tar_gz(archive, entries):
898
+ with tarfile.open(archive, "w:gz") as tf:
899
+ for name, data in entries:
900
+ info = tarfile.TarInfo(name)
901
+ info.size = len(data)
902
+ tf.addfile(info, io.BytesIO(data))
903
+
904
+
905
+ def _write_zip(archive, entries):
906
+ with zipfile.ZipFile(archive, "w") as zf:
907
+ for name, data in entries:
908
+ zf.writestr(name, data)
909
+
910
+
911
+ def _release_entries(names):
912
+ return [(name, f"payload of {name}".encode()) for name in names]
913
+
914
+
915
+ class ArchiveExtractionTests(unittest.TestCase):
916
+ URL = "https://example.invalid/release.tar.gz"
917
+
918
+ def _download(self, response, destination):
919
+ with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
920
+ _cli._download_file(self.URL, destination)
921
+
922
+ def test_download_stores_a_body_whose_declared_length_exceeds_the_former_cap(
923
+ self,
924
+ ):
925
+ with tempfile.TemporaryDirectory() as root:
926
+ destination = os.path.join(root, "release.tar.gz")
927
+ response = _FakeHttpsResponse(
928
+ b"payload", {"Content-Length": str(_FORMER_MEMBER_CAP_BYTES + 1)}
929
+ )
930
+ self._download(response, destination)
931
+ with open(destination, "rb") as stored:
932
+ self.assertEqual(stored.read(), b"payload")
933
+
934
+ def test_download_removes_the_partial_file_when_the_body_fails(self):
935
+ with tempfile.TemporaryDirectory() as root:
936
+ destination = os.path.join(root, "release.tar.gz")
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))
941
+
942
+ def test_unix_tar_rejects_more_members_than_the_limit(self):
943
+ with tempfile.TemporaryDirectory() as root:
944
+ archive = Path(root) / "members.tar.gz"
945
+ _write_tar_gz(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
946
+ destination = Path(root) / "extract"
947
+ destination.mkdir()
948
+ with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
949
+ _cli._safe_extract_tar(
950
+ tf,
951
+ str(destination),
952
+ ("one", "two", "three"),
953
+ ("one",),
954
+ max_members=2,
955
+ )
956
+ self.assertIn("2-member safety limit", str(raised.exception))
957
+ self.assertFalse((destination / "one").exists())
958
+
959
+ def test_unix_tar_extracts_only_the_executable(self):
960
+ with tempfile.TemporaryDirectory() as root:
961
+ archive = Path(root) / "release.tar.gz"
962
+ _write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
963
+ destination = Path(root) / "extract"
964
+ destination.mkdir()
965
+ with tarfile.open(archive) as tf:
966
+ extracted = _cli._safe_extract_tar(
967
+ tf,
968
+ str(destination),
969
+ _cli._UNIX_ARCHIVE_NAMES,
970
+ ("codebase-memory-mcp",),
971
+ )
972
+ self.assertEqual(extracted, ("codebase-memory-mcp",))
973
+ self.assertEqual(
974
+ (destination / "codebase-memory-mcp").read_bytes(),
975
+ b"payload of codebase-memory-mcp",
976
+ )
977
+ self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
978
+
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
984
+ )
985
+ self.assertEqual(copied, size)
986
+ self.assertEqual(sink.written, size)
987
+
988
+ def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
989
+ with self.assertRaises(SystemExit) as raised:
990
+ _cli._copy_archive_member(io.BytesIO(b"x" * 100), io.BytesIO(), "member", 10)
991
+ self.assertIn(
992
+ "'member' actual size 100 does not match declared size 10",
993
+ str(raised.exception),
994
+ )
995
+
996
+ def test_archive_member_write_removes_the_partial_file_on_failure(self):
997
+ with tempfile.TemporaryDirectory() as root:
998
+ target = os.path.join(root, "codebase-memory-mcp")
999
+ with self.assertRaises(SystemExit):
1000
+ _cli._write_archive_member(
1001
+ io.BytesIO(b"x" * 100), target, "codebase-memory-mcp", 10
1002
+ )
1003
+ self.assertFalse(os.path.exists(target))
1004
+
1005
+ def test_windows_zip_rejects_more_members_than_the_limit(self):
1006
+ with tempfile.TemporaryDirectory() as root:
1007
+ archive = Path(root) / "members.zip"
1008
+ _write_zip(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
1009
+ destination = Path(root) / "extract"
1010
+ destination.mkdir()
1011
+ with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
1012
+ _cli._safe_extract_zip(
1013
+ zf,
1014
+ str(destination),
1015
+ ("one", "two", "three"),
1016
+ ("one",),
1017
+ max_members=2,
1018
+ )
1019
+ self.assertIn("2-member safety limit", str(raised.exception))
1020
+
1021
+ def test_windows_zip_extracts_only_the_executable(self):
1022
+ with tempfile.TemporaryDirectory() as root:
1023
+ archive = Path(root) / "release.zip"
1024
+ _write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
1025
+ destination = Path(root) / "extract"
1026
+ destination.mkdir()
1027
+ with zipfile.ZipFile(archive) as zf:
1028
+ extracted = _cli._safe_extract_zip(
1029
+ zf,
1030
+ str(destination),
1031
+ _cli._WINDOWS_ARCHIVE_NAMES,
1032
+ (_cli._WINDOWS_BINARY_NAME,),
1033
+ )
1034
+ self.assertEqual(extracted, (_cli._WINDOWS_BINARY_NAME,))
1035
+ self.assertEqual(
1036
+ (destination / _cli._WINDOWS_BINARY_NAME).read_bytes(),
1037
+ b"payload of codebase-memory-mcp.exe",
1038
+ )
1039
+ self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
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
+ )