codebase-memory-mcp 0.10.8__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.
@@ -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
@@ -86,3 +89,7 @@ soak-results-query-leak/
86
89
  # Scorecard as a binary artifact in source). Source only, never the build.
87
90
  /pkg/go/codebase-memory-mcp
88
91
  /pkg/go/codebase-memory-mcp.exe
92
+
93
+ # Nix build symlinks (nix build → ./result, ./result-1, …)
94
+ /result
95
+ /result-*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-memory-mcp
3
- Version: 0.10.8
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.10.8"
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.7.0 \
358
- --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
359
- --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
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
- 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)
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, dest: str, archive_names=(), extract_names=(), ui: bool = False
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, open(target, "xb") as output:
220
- shutil.copyfileobj(source, output)
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, dest: str, archive_names=(), extract_names=(), ui: bool = False
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
- for info in zf.infolist():
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, open(target, "xb") as output:
272
- shutil.copyfileobj(source, output)
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.8.1"
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
+