feat: bug-report collector pseudonymises identities in two stages, opt-in via --pseudonymise (#158)
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:
1 parent
0899c670fe
commit
76d67e45ba
9 files changed
+850
-15
No files matched your search
@@ -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"]
|
||||
Reference in new issue
Block a user