From ddde05f365407fdfff2ac8074249e926d1908387 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 05:02:34 +0100 Subject: [PATCH 1/2] Extract dogfooding path redaction helper --- scripts/dogfooding/dogfood-snapshot.py | 60 +++++++++++++++------ scripts/dogfooding/test_dogfood_snapshot.py | 58 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 scripts/dogfooding/test_dogfood_snapshot.py diff --git a/scripts/dogfooding/dogfood-snapshot.py b/scripts/dogfooding/dogfood-snapshot.py index ae208b2f6..fff28cf55 100644 --- a/scripts/dogfooding/dogfood-snapshot.py +++ b/scripts/dogfooding/dogfood-snapshot.py @@ -38,7 +38,9 @@ from __future__ import annotations import argparse +import ntpath import os +import posixpath import sqlite3 import sys import urllib.parse @@ -120,23 +122,51 @@ def connect(path: str) -> sqlite3.Connection: return con -def redact(path: str) -> str: - """Home-relative display path. Output is meant to be pasted into public issues, and an - absolute path carries the OS username and often a client-specific directory name.""" +def _trim_path_root(path: str, path_module) -> str: + drive, tail = path_module.splitdrive(path) + trimmed = tail.rstrip("\\/") + if trimmed: + return drive + trimmed + if tail: + return drive + tail[:1] + return drive + + +def _record_safe_path(path: str) -> str: + return "".join( + character if character.isprintable() and character != "`" else f"\\x{ord(character):02x}" + for character in path + ) + + +def redact(path: str, *, home: str | None = None, windows: bool | None = None) -> str: + """Return a path-flavour-aware, record-safe home-relative display path. + + ``home`` and ``windows`` are injectable so tests can exercise Windows and POSIX + fixtures on either host without changing process-global environment state. + """ + use_windows = os.name == "nt" if windows is None else windows + path_module = ntpath if use_windows else posixpath try: - home = os.path.abspath(os.path.expanduser("~")).rstrip("\\/") - full = os.path.abspath(path) - rest = full[len(home):] - # Boundary matters: with home /home/alice, the path /home/alice-client/acme/db would - # otherwise render as "~-client/acme/db" and leak the directories it was meant to hide. - # Case-fold ONLY on Windows: on a case-sensitive filesystem /home/Alice and /home/alice - # are different directories, and folding would render an outside-home path as "~/...". - a, b = (full.lower(), home.lower()) if os.name == "nt" else (full, home) - if a.startswith(b) and (rest == "" or rest[0] in "\\/"): - return "~" + rest.replace("\\", "/") - return os.path.basename(full) + configured_home = os.path.expanduser("~") if home is None else home + home_path = _trim_path_root(path_module.abspath(configured_home), path_module) + full_path = _trim_path_root(path_module.abspath(path), path_module) + home_compare = path_module.normcase(home_path) + full_compare = path_module.normcase(full_path) + + if full_compare == home_compare: + redacted = "~" + else: + home_is_root = home_path.endswith(("\\", "/")) + boundary = home_compare if home_is_root else home_compare + path_module.sep + if full_compare.startswith(boundary): + rest = full_path[len(home_path):].replace("\\", "/") + redacted = "~/" + rest.lstrip("/") if home_is_root else "~" + rest + else: + redacted = path_module.basename(full_path) + return _record_safe_path(redacted) except Exception: - return os.path.basename(path) + return _record_safe_path(path_module.basename(path)) def has_table(con: sqlite3.Connection, name: str) -> bool: diff --git a/scripts/dogfooding/test_dogfood_snapshot.py b/scripts/dogfooding/test_dogfood_snapshot.py new file mode 100644 index 000000000..6941a54d5 --- /dev/null +++ b/scripts/dogfooding/test_dogfood_snapshot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("dogfood-snapshot.py") +SPEC = importlib.util.spec_from_file_location("dogfood_snapshot", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class RedactPathTests(unittest.TestCase): + def test_posix_home_boundary_and_case_are_preserved(self) -> None: + cases = ( + ("/home/alice", "/home/alice", False, "~"), + ("/home/alice/project/taskdeck.db", "/home/alice", False, "~/project/taskdeck.db"), + ("/home/alice-client/acme/taskdeck.db", "/home/alice", False, "taskdeck.db"), + ("/home/Alice/project/taskdeck.db", "/home/alice", False, "taskdeck.db"), + ) + + for path, home, windows, expected in cases: + with self.subTest(path=path): + self.assertEqual(MODULE.redact(path, home=home, windows=windows), expected) + + def test_windows_drive_and_unc_roots_use_windows_boundaries(self) -> None: + cases = ( + ("C:\\Users\\Alice\\Project\\taskdeck.db", "C:\\Users\\alice", "~/Project/taskdeck.db"), + ("C:\\taskdeck.db", "C:\\", "~/taskdeck.db"), + ("\\\\server\\share\\alice\\Project\\taskdeck.db", "\\\\server\\share\\alice\\", "~/Project/taskdeck.db"), + ) + + for path, home, expected in cases: + with self.subTest(path=path): + self.assertEqual(MODULE.redact(path, home=home, windows=True), expected) + + def test_relative_paths_and_home_root_remain_shareable(self) -> None: + self.assertEqual( + MODULE.redact("relative/taskdeck.db", home="/home/alice", windows=False), + "taskdeck.db", + ) + self.assertEqual( + MODULE.redact("/etc/taskdeck.db", home="/", windows=False), + "~/etc/taskdeck.db", + ) + + def test_control_characters_are_escaped_without_disclosing_the_home(self) -> None: + result = MODULE.redact("/home/alice/notes/line\nbreak\x00.db", home="/home/alice", windows=False) + + self.assertEqual(result, r"~/notes/line\x0abreak\x00.db") + self.assertNotIn("\n", result) + self.assertNotIn("alice", result) + + +if __name__ == "__main__": + unittest.main() From d952d85985564acd4bb5fc3f7c6a79b967e815ba Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 12:58:25 +0100 Subject: [PATCH 2/2] fix(dogfooding): redact the missing-database exit message too (review MEDIUM-2) --- scripts/dogfooding/dogfood-snapshot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/dogfooding/dogfood-snapshot.py b/scripts/dogfooding/dogfood-snapshot.py index fff28cf55..8d6df898c 100644 --- a/scripts/dogfooding/dogfood-snapshot.py +++ b/scripts/dogfooding/dogfood-snapshot.py @@ -109,7 +109,9 @@ def find_db(explicit: str | None) -> str: def connect(path: str) -> sqlite3.Connection: if not os.path.exists(path): - sys.exit(f"No such database: {path}") + # The path came from --db or $TASKDECK_DOGFOOD_DB and may carry a username or org name; + # the exit message is the one line most likely to be pasted into an issue, so redact it too. + sys.exit(f"No such database: {redact(path)}") # The path goes into a URI, so `?`, `#` and friends would otherwise change which file # SQLite opens (or silently drop the mode=ro). uri = "file:" + urllib.parse.quote(os.path.abspath(path).replace("\\", "/")) + "?mode=ro"