memdebug 0.2.0__py3-none-any.whl

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.
memdebug/selftest.py ADDED
@@ -0,0 +1,482 @@
1
+ """`memdebug selftest`: prove on THIS machine that the protections work.
2
+
3
+ Several protections depend on the operating system and on the installed git, so a green test suite
4
+ on another platform proves little. Each check here either demonstrates a protection, or says SKIP
5
+ and why. A SKIP is not a pass.
6
+
7
+ Where a check guards against an attack, it first runs a CONTROL that shows the attack really works
8
+ on this machine without the protection. If the control cannot be made to work, the check is a SKIP,
9
+ because "nothing bad happened" would prove nothing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Callable
24
+
25
+ from .adapters.common import Warnings
26
+ from .adapters.markdown_git import (
27
+ MarkdownGitAdapter,
28
+ _clean_env,
29
+ _Run,
30
+ _spawn_flags,
31
+ _valid_relpath,
32
+ find_git,
33
+ )
34
+ from .adapters.mem0 import Mem0Adapter
35
+ from .ledger import Ledger
36
+ from .models import Memory, MemoryEvent, Op
37
+ from .paths import default_ledger_path
38
+ from .textsafe import console_safe
39
+
40
+ Result = tuple[str, str, str] # name, PASS | FAIL | SKIP | INFO, detail
41
+
42
+
43
+ class _Skip(Exception):
44
+ pass
45
+
46
+
47
+ def _tmp() -> tempfile.TemporaryDirectory:
48
+ return tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
49
+
50
+
51
+ def _git_run(git: str, cwd: str | Path, *args: str, env: dict | None = None) -> subprocess.CompletedProcess:
52
+ return subprocess.run(
53
+ [git, "-c", "user.name=selftest", "-c", "user.email=selftest@example.invalid",
54
+ "-c", "commit.gpgsign=false", *args],
55
+ cwd=str(cwd), env=env or _clean_env(), capture_output=True, timeout=60, stdin=subprocess.DEVNULL,
56
+ )
57
+
58
+
59
+ def _git_version(git: str) -> tuple[int, int] | None:
60
+ out = subprocess.run([git, "--version"], capture_output=True, timeout=30, env=_clean_env()).stdout
61
+ match = re.search(rb"git version (\d+)\.(\d+)", out)
62
+ return (int(match.group(1)), int(match.group(2))) if match else None
63
+
64
+
65
+ def _need_git() -> str:
66
+ git = find_git()
67
+ if not git:
68
+ raise _Skip("git not found; the markdown backend is unavailable here")
69
+ version = _git_version(git)
70
+ if not version or version < (2, 31):
71
+ raise _Skip("git older than 2.31; the markdown backend is unavailable here")
72
+ return git
73
+
74
+
75
+ # -- checks ----------------------------------------------------------------------------------
76
+
77
+
78
+ def check_info() -> str:
79
+ git = find_git()
80
+ version = _git_version(git) if git else None
81
+ return (f"{sys.platform}, python {sys.version.split()[0]}, "
82
+ f"git {'.'.join(map(str, version)) if version else 'not found'}"
83
+ + (f" at {git}" if git else ""))
84
+
85
+
86
+ def check_git_ignores_global_config() -> str:
87
+ git = _need_git()
88
+ with _tmp() as home:
89
+ Path(home, ".gitconfig").write_text("[memdebugtest]\n\tkey = leaked\n", encoding="utf-8")
90
+ leak_env = {**_clean_env(), "HOME": home, "USERPROFILE": home, "XDG_CONFIG_HOME": home}
91
+ control_env = {k: v for k, v in leak_env.items() if k != "GIT_CONFIG_GLOBAL"}
92
+ control = _git_run(git, home, "config", "--get", "memdebugtest.key", env=control_env)
93
+ if b"leaked" not in control.stdout:
94
+ raise _Skip("could not show that a global config is normally read here, so isolation is unproven")
95
+ ours = _git_run(git, home, "config", "--get", "memdebugtest.key", env=leak_env)
96
+ if b"leaked" in ours.stdout:
97
+ raise AssertionError("git still read a global config although it was switched off")
98
+ return "a global git config pointed at by HOME is not read"
99
+
100
+
101
+ def check_hostile_repo_config_cannot_run_programs() -> str:
102
+ git = _need_git()
103
+ with _tmp() as base:
104
+ base_path = Path(base)
105
+ marker = base_path / "PWNED"
106
+ script = base_path / "evil.py"
107
+ script.write_text(f"open({str(marker)!r}, 'w').write('x')\n", encoding="utf-8")
108
+ command = f'"{Path(sys.executable).as_posix()}" "{script.as_posix()}"'
109
+ repo = base_path / "repo"
110
+ repo.mkdir()
111
+ _git_run(git, repo, "init", "-q", "-b", "main")
112
+ for version in ("one", "two"):
113
+ (repo / "a.md").write_text(version + "\n", encoding="utf-8")
114
+ _git_run(git, repo, "add", "-A")
115
+ _git_run(git, repo, "commit", "-q", "-m", version)
116
+ for key in ("diff.external", "core.fsmonitor", "core.pager", "pager.log", "core.editor"):
117
+ _git_run(git, repo, "config", key, command)
118
+ _git_run(git, repo, "config", "core.hooksPath", str(base_path))
119
+
120
+ subprocess.run([git, "log", "-p", "--ext-diff", "--no-color"], cwd=str(repo), env=_clean_env(),
121
+ capture_output=True, timeout=60, stdin=subprocess.DEVNULL)
122
+ if not marker.exists():
123
+ raise _Skip("could not make a hostile config run a program here, so the protection is unproven")
124
+ marker.unlink()
125
+
126
+ adapter = MarkdownGitAdapter(repo, store="selftest")
127
+ adapter.read_history(100)
128
+ adapter.history("a.md")
129
+ adapter.list_memories({"store": "selftest"})
130
+ if marker.exists():
131
+ raise AssertionError("a program named in the repository's config was executed")
132
+ return "a repository config that runs programs did not run any"
133
+
134
+
135
+ def check_history_file_is_read_only() -> str:
136
+ class _Stub:
137
+ def get_all(self, **kw):
138
+ return {"results": []}
139
+
140
+ def history(self, memory_id):
141
+ return []
142
+
143
+ with _tmp() as base:
144
+ path = Path(base) / "history.db"
145
+ conn = sqlite3.connect(path)
146
+ conn.execute("CREATE TABLE history (id TEXT PRIMARY KEY, memory_id TEXT, old_memory TEXT, "
147
+ "new_memory TEXT, event TEXT, created_at DATETIME, updated_at DATETIME)")
148
+ conn.execute("INSERT INTO history VALUES ('1','m','', 'x','ADD','2026-10-04T00:00:00+00:00',NULL)")
149
+ conn.commit()
150
+ conn.close()
151
+ before = hashlib.sha256(path.read_bytes()).hexdigest()
152
+ adapter = Mem0Adapter(_Stub(), path)
153
+ adapter.read_history(10)
154
+ ro = adapter._connect_ro()
155
+ try:
156
+ ro.execute("PRAGMA query_only = OFF")
157
+ try:
158
+ ro.execute("INSERT INTO history (id, memory_id) VALUES ('x', 'y')")
159
+ except sqlite3.OperationalError:
160
+ pass
161
+ else:
162
+ raise AssertionError("the history connection accepted a write")
163
+ finally:
164
+ ro.close()
165
+ if hashlib.sha256(path.read_bytes()).hexdigest() != before:
166
+ raise AssertionError("the history file changed")
167
+ missing = Path(base) / "nope.db"
168
+ try:
169
+ Mem0Adapter(_Stub(), missing)
170
+ except Exception:
171
+ pass
172
+ if missing.exists():
173
+ raise AssertionError("a missing history file was created")
174
+ return "history is opened read-only; the file is never changed or created"
175
+
176
+
177
+ def check_hung_process_tree_is_killed() -> str:
178
+ code = ("import subprocess, sys, time; "
179
+ "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(40)']); time.sleep(40)")
180
+ errfile = tempfile.TemporaryFile()
181
+ proc = subprocess.Popen([sys.executable, "-c", code], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
182
+ stderr=errfile, **_spawn_flags())
183
+ run = _Run(proc, errfile, timeout=1.0)
184
+ started = time.monotonic()
185
+ try:
186
+ if proc.stdout is None:
187
+ raise AssertionError("the test process has no output pipe")
188
+ proc.stdout.read() # returns only when every holder of the pipe, children included, is gone
189
+ elapsed = time.monotonic() - started
190
+ finally:
191
+ run.close()
192
+ if elapsed > 15:
193
+ raise AssertionError(f"a stalled process and its child were only stopped after {elapsed:.0f}s")
194
+ return f"a stalled process and its child were stopped after {elapsed:.1f}s"
195
+
196
+
197
+ def check_symlinks_are_not_followed() -> str:
198
+ with _tmp() as base:
199
+ secret = Path(base) / "secret.txt"
200
+ secret.write_text("TOP SECRET TOKEN", encoding="utf-8")
201
+ link = Path(base) / "link.md"
202
+ try:
203
+ os.symlink(secret, link)
204
+ except (OSError, NotImplementedError, AttributeError):
205
+ raise _Skip("cannot create symlinks here (Windows needs Developer Mode or administrator rights)") from None
206
+ warnings = Warnings()
207
+ text = MarkdownGitAdapter._read_working_file(str(link), "link.md", warnings)
208
+ if text is not None or "TOP SECRET" in repr(warnings.as_list()):
209
+ raise AssertionError("a symlink was followed")
210
+ return "a symlink pointing at a secret file was not read"
211
+
212
+
213
+ def check_junctions_are_not_followed() -> str:
214
+ if os.name != "nt":
215
+ raise _Skip("Windows only")
216
+ git = _need_git()
217
+ with _tmp() as base:
218
+ outside = Path(base) / "outside"
219
+ outside.mkdir()
220
+ (outside / "secret.md").write_text("TOP SECRET TOKEN", encoding="utf-8")
221
+ repo = Path(base) / "repo"
222
+ repo.mkdir()
223
+ _git_run(git, repo, "init", "-q", "-b", "main")
224
+ (repo / "ok.md").write_text("fine\n", encoding="utf-8")
225
+ cmd = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "cmd.exe")
226
+ made = subprocess.run([cmd, "/c", "mklink", "/J", str(repo / "junc"), str(outside)],
227
+ capture_output=True, timeout=30)
228
+ if made.returncode != 0:
229
+ raise _Skip("could not create a directory junction here")
230
+ live = MarkdownGitAdapter(repo, store="selftest").list_memories({"store": "selftest"})
231
+ if any("secret" in m.id or "TOP SECRET" in m.text for m in live.memories) or live.complete:
232
+ raise AssertionError("a directory junction was followed")
233
+ return "a directory junction pointing outside the repository was not followed"
234
+
235
+
236
+ def check_dangerous_names_are_rejected() -> str:
237
+ bad = ["NUL.md", "con.md", "aux/x.md", "notes:hidden.md", "C:evil.md", "a.md.", "dir /x.md", ".GIT/x.md", "GIT~1/x.md",
238
+ "a<b>.md", 'q"uote.md', "../x.md", "/abs.md", "COM1.md", "lpt9.txt.md", "a\\b.md", "x\ny.md"]
239
+ good = ["ok/fine.md", "caf\u00e9.md", "People/Ann.md", "-rf.md"]
240
+ wrongly_ok = [p for p in bad if _valid_relpath(p, (".md",)) is not None]
241
+ wrongly_bad = [p for p in good if _valid_relpath(p, (".md",)) is None]
242
+ if wrongly_ok or wrongly_bad:
243
+ raise AssertionError(f"accepted {wrongly_ok!r}; rejected {wrongly_bad!r}")
244
+ return f"{len(bad)} dangerous names rejected, {len(good)} normal names accepted"
245
+
246
+
247
+ def check_console_survives_unencodable_text() -> str:
248
+ for encoding in ("cp1252", "ascii", "cp437"):
249
+ shown = console_safe("\u65e5\u672c\u8a9e \U0001f600 caf\u00e9", encoding)
250
+ shown.encode(encoding) # must be representable
251
+ if "\u65e5" in shown:
252
+ raise AssertionError("characters outside the console encoding were not escaped")
253
+ return "text a console cannot show is escaped instead of crashing"
254
+
255
+
256
+ def check_ledger_tamper_detection() -> str:
257
+ from .models import Memory
258
+
259
+ with _tmp() as base:
260
+ path = Path(base) / "ledger.db"
261
+ ledger = Ledger(path)
262
+ now = datetime.now(timezone.utc)
263
+ ledger.append(MemoryEvent(backend="t", memory_id="m", op=Op.ADD, ts=now, after="x"))
264
+ info = ledger.save_snapshot("t", {"user_id": "u"}, [Memory(id="m", text="the original")],
265
+ complete=True, taken_at=now)
266
+ if not ledger.verify().ok:
267
+ raise AssertionError("a freshly written ledger did not verify")
268
+ conn = sqlite3.connect(path)
269
+ conn.execute("UPDATE events SET payload = replace(payload, '\"x\"', '\"y\"') WHERE seq = 1")
270
+ conn.commit()
271
+ if ledger.verify().ok:
272
+ raise AssertionError("an edited ledger entry was not detected")
273
+ conn.execute("UPDATE events SET payload = replace(payload, '\"y\"', '\"x\"') WHERE seq = 1")
274
+ conn.execute("UPDATE blobs SET text = 'forged'")
275
+ conn.commit()
276
+ conn.close()
277
+ if ledger.verify().ok:
278
+ raise AssertionError("an edited snapshot text was not detected")
279
+ try:
280
+ ledger.load_snapshot(info.id)
281
+ except Exception:
282
+ pass
283
+ else:
284
+ raise AssertionError("an edited snapshot was loaded without complaint")
285
+ ledger.close()
286
+ return "an edited ledger entry and an edited snapshot are both detected"
287
+
288
+
289
+ def check_ledger_file_permissions() -> str:
290
+ if os.name != "posix":
291
+ raise _Skip(f"Windows permissions are not set by this tool; the default ledger lives in "
292
+ f"{default_ledger_path().parent}, which is private to your user account")
293
+ with _tmp() as base:
294
+ path = Path(base) / "ledger.db"
295
+ Ledger(path).close()
296
+ mode = path.stat().st_mode & 0o777
297
+ if mode != 0o600:
298
+ raise AssertionError(f"new ledger file has mode {mode:o}, expected 600")
299
+ return "a new ledger file is owner-only (600)"
300
+
301
+
302
+ def check_viewer_is_local_and_protected() -> str:
303
+ import http.client
304
+ import socket
305
+ import threading
306
+
307
+ from .viewer.server import ViewerServer
308
+
309
+ with _tmp() as base:
310
+ path = Path(base) / "ledger.db"
311
+ ledger = Ledger(path)
312
+ ledger.append(MemoryEvent(backend="t", memory_id="m", op=Op.ADD, ts=datetime.now(timezone.utc), after="x"))
313
+ ledger.close()
314
+ server = ViewerServer(path, port=0)
315
+ thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
316
+ thread.start()
317
+ notes = []
318
+ try:
319
+ listening = str(server.server_address[0])
320
+ if listening != "127.0.0.1":
321
+ raise AssertionError(f"the viewer is listening on {listening}, not only on this computer")
322
+
323
+ def ask(method="GET", host=None, cookie=True, target="/"):
324
+ conn = http.client.HTTPConnection("127.0.0.1", server.port, timeout=10)
325
+ conn.putrequest(method, target, skip_host=True, skip_accept_encoding=True)
326
+ conn.putheader("Host", host or f"127.0.0.1:{server.port}")
327
+ if cookie:
328
+ conn.putheader("Cookie", f"{server.state.cookie_name}={server.token}")
329
+ conn.endheaders()
330
+ status = conn.getresponse().status
331
+ conn.close()
332
+ return status
333
+
334
+ checks = [("no secret", ask(cookie=False), 403), ("another host name", ask(host="evil.example"), 421),
335
+ ("POST", ask("POST"), 405), ("the real request", ask(), 200)]
336
+ for name, got, want in checks:
337
+ if got != want:
338
+ raise AssertionError(f"{name}: expected {want}, got {got}")
339
+
340
+ def second_bind_succeeds() -> bool:
341
+ other = socket.socket()
342
+ try:
343
+ other.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
344
+ other.bind(("127.0.0.1", server.port))
345
+ return True
346
+ except OSError:
347
+ return False
348
+ finally:
349
+ other.close()
350
+
351
+ if second_bind_succeeds():
352
+ raise AssertionError("another program could bind the viewer's port and receive its requests")
353
+ try:
354
+ ViewerServer(path, port=server.port).server_close()
355
+ raise AssertionError("a second viewer was allowed to take the same port")
356
+ except OSError:
357
+ pass
358
+ if os.name == "nt": # control: show the hazard is real here without the protection
359
+ a, b = socket.socket(), socket.socket()
360
+ try:
361
+ for sock in (a, b):
362
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
363
+ a.bind(("127.0.0.1", 0))
364
+ a.listen(1)
365
+ try:
366
+ b.bind(a.getsockname())
367
+ notes.append("control: an ordinary socket CAN share a port on this system, so the exclusive setting matters")
368
+ except OSError:
369
+ notes.append("control: this system does not let ordinary sockets share a port")
370
+ finally:
371
+ a.close()
372
+ b.close()
373
+ finally:
374
+ server.shutdown()
375
+ server.server_close()
376
+ thread.join(5)
377
+ return "; ".join(["listens on this computer only, refuses missing secret, wrong host name and writes, and its port cannot be shared"] + notes)
378
+
379
+
380
+ def check_rollback_is_safe() -> str:
381
+ from .adapters.restore import Restorer
382
+
383
+ git = _need_git()
384
+ with _tmp() as base:
385
+ base_path = Path(base)
386
+ repo = base_path / "repo"
387
+ repo.mkdir()
388
+ trap = base_path / "trap.py"
389
+ trap.write_text("import sys, pathlib\npathlib.Path(sys.argv[1]).write_text('x', encoding='utf-8')\nsys.stdout.buffer.write(sys.stdin.buffer.read())\n",
390
+ encoding="utf-8")
391
+ markers = {name: base_path / f"PWNED-{name}" for name in ("filter", "diff", "hook", "fsmonitor")}
392
+
393
+ def command(name: str) -> str:
394
+ return f'"{Path(sys.executable).as_posix()}" "{trap.as_posix()}" "{markers[name].as_posix()}"'
395
+
396
+ _git_run(git, repo, "init", "-q", "-b", "main")
397
+ original = b"alpha\r\nbeta\r\n" # Windows line endings: a snapshot's normalised text cannot reproduce these
398
+ (repo / "a.md").write_bytes(original)
399
+ (repo / ".gitattributes").write_text("*.md filter=trap\n", encoding="utf-8")
400
+ _git_run(git, repo, "add", "-A")
401
+ _git_run(git, repo, "commit", "-q", "-m", "one")
402
+ (repo / "a.md").write_bytes(b"changed\n")
403
+ _git_run(git, repo, "add", "-A")
404
+ _git_run(git, repo, "commit", "-q", "-m", "two")
405
+
406
+ hooks = base_path / "hooks"
407
+ hooks.mkdir()
408
+ for hook in ("pre-commit", "post-commit", "reference-transaction", "post-index-change"):
409
+ (hooks / hook).write_text(f"#!/bin/sh\ntouch '{markers['hook'].as_posix()}'\n", encoding="utf-8")
410
+ (hooks / hook).chmod(0o755)
411
+ for key, value in (("filter.trap.clean", command("filter")), ("filter.trap.smudge", command("filter")),
412
+ ("diff.external", command("diff")), ("core.fsmonitor", command("fsmonitor")),
413
+ ("core.hooksPath", str(hooks))):
414
+ _git_run(git, repo, "config", key, value)
415
+
416
+ # CONTROL: with ordinary git, each of these traps fires on this machine.
417
+ raw = _clean_env()
418
+ subprocess.run([git, "hash-object", "a.md"], cwd=str(repo), env=raw, capture_output=True, timeout=60, stdin=subprocess.DEVNULL)
419
+ subprocess.run([git, "log", "-p", "--ext-diff", "--no-color"], cwd=str(repo), env=raw, capture_output=True, timeout=60,
420
+ stdin=subprocess.DEVNULL)
421
+ _git_run(git, repo, "commit", "-q", "--allow-empty", "-m", "control")
422
+ armed = [name for name, marker in markers.items() if marker.exists()]
423
+ if "filter" not in armed and "diff" not in armed:
424
+ raise _Skip("could not make a hostile repository run a program here, so the protection is unproven")
425
+ for marker in markers.values():
426
+ marker.unlink(missing_ok=True)
427
+
428
+ ledger = Ledger(base_path / "ledger.db")
429
+ scope = {"store": "selftest"}
430
+ info = ledger.save_snapshot("markdown-git", scope, [Memory(id="a.md", text="alpha\nbeta\n", scope=scope)], complete=True,
431
+ taken_at=datetime.now(timezone.utc))
432
+ (repo / "a.md").write_bytes(b"precious unsaved work\n") # an uncommitted edit the rollback will replace
433
+ adapter = MarkdownGitAdapter(repo, store="selftest")
434
+ restorer = Restorer(adapter)
435
+ snapshot = ledger.load_snapshot(info.id)
436
+ plan = restorer.plan(snapshot)
437
+ outcome = restorer.apply(snapshot, expected_plan_id=plan.plan_id)
438
+
439
+ ran = [name for name, marker in markers.items() if marker.exists()]
440
+ if ran:
441
+ raise AssertionError("a program from the repository ran during the rollback: " + ", ".join(ran))
442
+ if (repo / "a.md").read_bytes() != original:
443
+ raise AssertionError("the original bytes (with their line endings) were not restored exactly")
444
+ code, author = adapter.git.run_small(["log", "-1", "--format=%an"])
445
+ code2, saved = adapter.git.run_small(["show", f"{outcome.backup_ref}:a.md"])
446
+ if code != 0 or author.strip() != b"memdebug" or outcome.commit is None:
447
+ raise AssertionError("the rollback did not make one new commit")
448
+ if outcome.backup_ref is None or code2 != 0 or saved != b"precious unsaved work\n":
449
+ raise AssertionError("the content the rollback replaced was not saved first")
450
+ return ("restored exact bytes in a new commit, saved the replaced edit first, and ran no hook, filter or helper program; "
451
+ f"control: {', '.join(armed)} do fire without the protection")
452
+
453
+
454
+
455
+ CHECKS: list[tuple[str, Callable[[], str]]] = [
456
+ ("git ignores global config", check_git_ignores_global_config),
457
+ ("hostile repository config cannot run programs", check_hostile_repo_config_cannot_run_programs),
458
+ ("history file is opened read-only", check_history_file_is_read_only),
459
+ ("a hung process tree is killed", check_hung_process_tree_is_killed),
460
+ ("symlinks are not followed", check_symlinks_are_not_followed),
461
+ ("directory junctions are not followed", check_junctions_are_not_followed),
462
+ ("dangerous file names are rejected", check_dangerous_names_are_rejected),
463
+ ("console survives unencodable text", check_console_survives_unencodable_text),
464
+ ("ledger tamper detection", check_ledger_tamper_detection),
465
+ ("ledger file permissions", check_ledger_file_permissions),
466
+ ("viewer is local-only and protected", check_viewer_is_local_and_protected),
467
+ ("rollback is safe", check_rollback_is_safe),
468
+ ]
469
+
470
+
471
+ def run_all() -> list[Result]:
472
+ results: list[Result] = [("environment", "INFO", check_info())]
473
+ for name, check in CHECKS:
474
+ try:
475
+ results.append((name, "PASS", check()))
476
+ except _Skip as skip:
477
+ results.append((name, "SKIP", str(skip)))
478
+ except AssertionError as failure:
479
+ results.append((name, "FAIL", str(failure)))
480
+ except Exception as exc: # a check that cannot even run is not a pass
481
+ results.append((name, "FAIL", f"could not run: {type(exc).__name__}"))
482
+ return results