Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions scripts/dogfooding/dogfood-snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
from __future__ import annotations

import argparse
import ntpath
import os
import posixpath
import sqlite3
import sys
import urllib.parse
Expand Down Expand Up @@ -107,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"
Expand All @@ -120,23 +124,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:
Expand Down
58 changes: 58 additions & 0 deletions scripts/dogfooding/test_dogfood_snapshot.py
Original file line number Diff line number Diff line change
@@ -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()
Loading