feat: bug-report collector pseudonymises identities in two stages, opt-in via --pseudonymise (#158)
CI / verify (push) Successful in 2m5s
Release / release (push) Successful in 39s

Files changed:
- .gitea/workflows/ci.yml
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/bug-report.md
- reports/CONTRACT.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/tests/test_bugreport.py
This commit is contained in:
torben committed 2026-09-30 19:03:59 +02:00
1 parent 0899c670fe
commit 76d67e45ba
9 files changed
+850 -15

No files matched your search

+231
View File
@@ -325,3 +325,234 @@ def test_the_collector_ships_with_a_distribution():
from chemenu.commands import dist_cmd
assert "tools/bugreport.py" in dist_cmd.build_plan()
# ------------------------------------------------------------------ pseudonymisation
REMOTE = "C:\\Users\\Max.Muster\\OneDrive - Beispiel GmbH\\x.git"
REPO_ROOT = Path(SCRIPT).resolve().parent.parent
def machine(checkout: Path, monkeypatch, user: str = "Max") -> None:
for name in ("USER", "USERNAME", "LOGNAME"):
monkeypatch.setenv(name, user)
monkeypatch.setenv("COMPUTERNAME", "DESKTOP-Zx91Q")
monkeypatch.setenv("USERDOMAIN", "AB")
monkeypatch.setattr(bugreport.socket, "gethostname", lambda: "Rechner-Delta")
git(checkout, "config", "user.name", "Erika Mustermann")
git(checkout, "config", "user.email", "erika@beispiel-firma.example")
remotes = subprocess.run(["git", "remote"], cwd=checkout, capture_output=True, text=True).stdout
if "corp" not in remotes.split():
git(checkout, "remote", "add", "corp", REMOTE)
def pseudonymised(checkout, tmp_path, monkeypatch, *extra, user="Max", name="out"):
machine(checkout, monkeypatch, user)
out = tmp_path / name
assert bugreport.main(
["--root", str(checkout), "--out", str(out), "--no-trace", "--pseudonymise", *extra]
) == 0
return next(p for p in out.iterdir() if p.is_dir())
def zip_text(bundle: Path) -> str:
archive = bundle.with_name(bundle.name + ".zip")
with zipfile.ZipFile(archive) as zf:
return "\n".join(zf.read(n).decode("utf-8", errors="replace") for n in zf.namelist())
def mapping_of(bundle: Path) -> Path:
return bundle.with_name(bundle.name + ".pseudonyms.json")
def snapshot(bundle: Path) -> dict:
return {p.relative_to(bundle).as_posix(): p.read_bytes() for p in bundle.rglob("*") if p.is_file()}
def test_a_remote_keeps_its_shape_and_loses_its_names(checkout, tmp_path, monkeypatch):
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
assert "Max.Muster" not in all_text(bundle) and "Max.Muster" not in zip_text(bundle)
assert "Beispiel" not in all_text(bundle)
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
line = next(r for r in remotes if r.startswith("corp"))
original = f"corp\t{REMOTE} (fetch)".split("\\")
shaped = line.split("\\")
assert len(shaped) == len(original)
assert [len(part) for part in shaped] == [len(part) for part in original]
assert shaped[1] == "Users"
assert shaped[2][3] == "." and shaped[2] != "Max.Muster"
assert shaped[3].startswith("OneDrive - ") and shaped[3].endswith(" GmbH")
assert shaped[4].endswith(".git (fetch)")
def test_one_identity_gets_one_placeholder_in_every_file_and_form(checkout, tmp_path, monkeypatch):
name = "Müller-Lüdenscheidt"
monkeypatch.setenv("PATH", "/home/%s/bin%s%s" % (name, os.pathsep, os.environ["PATH"]))
trace_dir = checkout / "reports" / "telemetry" / "callers-session"
trace_dir.mkdir(parents=True)
(trace_dir / "trace.jsonl").write_text(json.dumps({"p": "/home/" + name}) + "\n")
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
chron = tmp_path / "chron.md"
chron.write_text("user %s and %s\n" % (name, name.upper()))
machine(checkout, monkeypatch, user=name)
out = tmp_path / "out"
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--pseudonymise",
"--chronology", str(chron)]) == 0
bundle = next(p for p in out.iterdir() if p.is_dir())
env_entry = json.loads((bundle / "environment.json").read_text())["path_entries"][0]["entry"]
from_env = env_entry.split("/")[2]
from_trace = json.loads((bundle / "trace.jsonl").read_text())["p"].split("/")[2]
lines = (bundle / "CHRONOLOGY.md").read_text().split()
assert from_env == from_trace == lines[1]
assert lines[3] == from_env.upper()
assert from_env != name and len(from_env) == len(name)
for original, shaped in zip(name, from_env):
assert (original == "-") == (shaped == "-")
assert original.isascii() == shaped.isascii()
assert original.isupper() == shaped.isupper()
assert name not in all_text(bundle) and "M\\u00fcller" not in all_text(bundle)
def test_placeholders_keep_length_classes_and_separators_and_are_injective():
pz = bugreport.Pseudonymiser(salt="00" * 16)
assert pz.add_identity("Müller-Lüdenscheidt 42", "user name")
pz.finish()
placeholder = pz.entries[0]["placeholder"]
original = pz.entries[0]["original"]
assert len(placeholder) == len(original)
for a, b in zip(original, placeholder):
assert a.isalpha() == b.isalpha() and a.isdigit() == b.isdigit()
assert a.isascii() == b.isascii() and a.isupper() == b.isupper()
if not a.isalnum():
assert a == b
assert pz.pseudo_text("Users GmbH") == "Users GmbH"
words = ["name%03d" % i for i in range(300)]
shaped = {pz.pseudo_word(w) for w in words}
assert len(shaped) == len(words)
assert not shaped & set(words)
def test_an_identity_matches_whole_words_only(checkout, tmp_path, monkeypatch):
chron = tmp_path / "chron.md"
chron.write_text("Maximum and Max_1 and Max here, Maxine too\n")
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron))
text = (bundle / "CHRONOLOGY.md").read_text()
assert "Maximum" in text and "Maxine" in text
assert text.count("Max") == 2 # "Maximum" and "Maxine" contain none at a word end; Max_1 and Max are replaced
def test_stage_two_applies_candidates_with_the_same_word_and_is_idempotent(
checkout, tmp_path, monkeypatch, capsys
):
chron = tmp_path / "chron.md"
chron.write_text("Firma Beispiel GmbH hat Zugriff. Kunde Acme Corp. Acme Corp zahlt.\n")
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron))
assert "Beispiel GmbH" in (bundle / "CHRONOLOGY.md").read_text()
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
stage_one_word = next(r for r in remotes if r.startswith("corp")).split("\\")[3].split()[2]
candidates = tmp_path / "candidates.txt"
candidates.write_text("# from the model\nBeispiel GmbH\nAcme Corp # customer\nab\nUsers\nnowhere at all\n")
capsys.readouterr()
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
printed = capsys.readouterr().out
for needle in ("not applied: ab", "not applied: Users", "not applied: nowhere at all",
"100 KB"):
assert needle in printed
chronology = (bundle / "CHRONOLOGY.md").read_text()
assert "Acme" not in chronology and "Beispiel" not in chronology
assert chronology.startswith("Firma %s GmbH hat" % stage_one_word)
assert "Acme" not in zip_text(bundle) and "Beispiel" not in zip_text(bundle)
first = snapshot(bundle)
mapping_before = mapping_of(bundle).read_bytes()
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
assert snapshot(bundle) == first
assert mapping_of(bundle).read_bytes() == mapping_before
def test_the_mapping_review_and_candidates_stay_beside_the_bundle_and_stage_two_needs_the_mapping(
checkout, tmp_path, monkeypatch, capsys
):
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
review = bundle.with_name(bundle.name + ".review.txt")
assert mapping_of(bundle).is_file() and review.is_file()
assert "Max.Muster" in mapping_of(bundle).read_text()
archive = bundle.with_name(bundle.name + ".zip")
with zipfile.ZipFile(archive) as zf:
assert not [n for n in zf.namelist() if "pseudonyms" in n or "review" in n]
assert not [p for p in bundle.rglob("*") if "pseudonyms" in p.name or "review" in p.name]
candidates = tmp_path / "candidates.txt"
candidates.write_text("Erika\n")
mapping_of(bundle).unlink()
before, archive_before = snapshot(bundle), archive.read_bytes()
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 1
assert "collect the report again" in capsys.readouterr().err
assert snapshot(bundle) == before and archive.read_bytes() == archive_before
def test_the_manifest_has_three_privacy_versions_and_never_an_original(
checkout, tmp_path, monkeypatch
):
plain = collect(checkout, tmp_path / "plain", "--no-trace")
assert "not pseudonymised" in (plain / "MANIFEST.md").read_text()
assert "## Pseudonyms" not in (plain / "MANIFEST.md").read_text()
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
manifest = (bundle / "MANIFEST.md").read_text()
assert "stage 1 applied, stage 2 not yet applied" in manifest
assert "| Placeholder | Kind | Stage | Occurrences |" in manifest
for original in ("Max.Muster", "Beispiel", "Rechner-Delta", "Mustermann", "beispiel-firma"):
assert original not in manifest
candidates = tmp_path / "candidates.txt"
candidates.write_text("Erika\n")
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
manifest = (bundle / "MANIFEST.md").read_text()
assert "stage 1 and stage 2 applied" in manifest
assert "judgement of a model" in manifest and "100 KB" in manifest
assert "Erika" not in manifest
instruction = (REPO_ROOT / "instructions" / "bug-report.md").read_text()
assert "100 KB" in instruction and "residual" in instruction.lower()
def test_two_bundles_of_one_machine_use_different_placeholders(checkout, tmp_path, monkeypatch):
monkeypatch.setattr(bugreport, "stamp_now", lambda: "20260101T000000Z")
first = pseudonymised(checkout, tmp_path, monkeypatch, name="a")
second = pseudonymised(checkout, tmp_path, monkeypatch, name="b")
def shaped(bundle):
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
return next(r for r in remotes if r.startswith("corp")).split("\\")[2]
assert shaped(first) != shaped(second)
def test_the_public_origin_short_and_vocabulary_identities_stay_readable(
checkout, tmp_path, monkeypatch
):
git(checkout, "remote", "add", "pub", "https://gitea.nehmer.net/torben/chemenu.git")
chron = tmp_path / "chron.md"
chron.write_text("torben was here; AB and DESKTOP stay\n")
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron), user="torben")
text = (bundle / "environment.json").read_text()
assert "gitea.nehmer.net/torben/chemenu.git" in text
chronology = (bundle / "CHRONOLOGY.md").read_text()
assert chronology.startswith("torben was") is False
assert "AB and DESKTOP stay" in chronology
manifest = (bundle / "MANIFEST.md").read_text()
assert int(manifest.split(" identities were left unchanged")[0].split()[-1]) >= 2
def test_bundle_and_candidates_exclude_the_collection_options(checkout, tmp_path):
for extra in (["--no-trace"], ["--titles"], ["--pseudonymise"], ["--root", str(checkout)]):
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b", "--candidates", "c", *extra])
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b"])
with pytest.raises(SystemExit):
bugreport.main(["--candidates", "c"])
def test_without_the_flag_nothing_but_the_bundle_and_its_archive_is_written(checkout, tmp_path):
collect(checkout, tmp_path, "--no-trace")
assert sorted(p.suffix for p in (tmp_path / "out").iterdir()) == ["", ".zip"]