diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 10:13:04 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 10:13:04 +0200 |
| commit | 2cdd4dba93b26691a85cd0977294687d5afe0670 (patch) | |
| tree | 6027db6b599b43138a7b1f81a08296f7e91cfa5e | |
| parent | 57dc93c3069ccac583533f4f2d4f9cbc2720c73e (diff) | |
| download | abusectl-2cdd4dba93b26691a85cd0977294687d5afe0670.tar.gz abusectl-2cdd4dba93b26691a85cd0977294687d5afe0670.zip | |
feat: freeze a reported case and detect edited bodies
Content hash rather than mtime, because mtime is wrong in both directions.
Any sent destination freezes the whole case with no override: two desks
holding contradictory accounts of one incident is worse than a stale body.
The marker is tested for PRESENCE rather than truthiness. It is write-once
and monotonic, so an empty or malformed value is a half-written record of a
case a desk may already hold, and the safe direction is to refuse; only an
explicit null counts as absent. A non-mapping marker refuses too rather than
raising AttributeError out of a .get, since cli turns Frozen into a message
and a traceback into a bug report.
The hash pins utf-8 rather than leaving the encoding to the locale: it is
compared against a body read back off disk, possibly under a different LANG,
and for a sent destination it is the record of what was disclosed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
| -rw-r--r-- | abusectl/report.py | 72 | ||||
| -rw-r--r-- | tests/test_report.py | 110 |
2 files changed, 182 insertions, 0 deletions
diff --git a/abusectl/report.py b/abusectl/report.py index 36bbbff..38a50ff 100644 --- a/abusectl/report.py +++ b/abusectl/report.py @@ -882,3 +882,75 @@ def build(manifest: dict, destination: dict, identity: dict) -> str: message.attach(headers) return message.as_string() + + +class Frozen(Exception): + """Raised when a case has already been reported to at least one desk. + + There is no force override. The destinations are not independent + artifacts, they are one incident reported in parallel: regenerating one + body after another desk holds the report leaves two desks with + contradictory accounts of the same case. Worse, the shared parts of + every body, the identity, the header block and the IOC list, come from + the manifest, so regenerating one body after the manifest has moved + produces a case whose bodies were built from two different states. + """ + + +class Modified(Exception): + """Raised when a body was edited after generation and --force was not + given. Silently discarding a review that took twenty minutes is what + makes a tool untrustworthy. + """ + + +def body_hash(text: str) -> str: + """Hash a body's CONTENT, which is what the question actually is. + + An mtime is a poor witness in both directions: a git checkout, an rsync + or a backup restore all move it with no human having edited anything, + and an editor that preserves mtime hides a real edit. For a destination + that has been sent, this hash is also the record of what was disclosed. + + The encoding is PINNED to utf-8 rather than left to the locale, because + the value is compared against a body read back off disk, possibly by a + later run under a different LANG. A body that hashed differently on a + machine set to a non-UTF-8 locale would read as hand-edited and refuse + to regenerate, and, for a sent destination, would be a false record of + what was disclosed. Whoever writes the file owes it the same encoding. + """ + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def check_regenerable(manifest: dict, modified: list[str], + force: bool = False) -> None: + """Raise unless this case may be regenerated. Returns None when it may. + + Frozen wins over force, and it is checked FIRST so that an edited body + on a frozen case raises Frozen rather than Modified: Modified is the one + --force can clear, and reporting the clearable half of a refusal invites + the user to force their way past the half that has no override. + + PRESENCE is the test, not truthiness. The marker is write-once and + monotonic, so `{}` or `""` is a half-written or corrupted record of a + case a desk may already hold, and the safe direction for an evidence + record is to refuse. Only an explicit null counts as absent, because + JSON has no way to omit a key it wrote as null. A marker that is not a + mapping refuses too, rather than raising AttributeError out of a .get: + cli turns Frozen into a message and a traceback into a bug report. + """ + if "frozen" in manifest and manifest["frozen"] is not None: + frozen = manifest["frozen"] + details = frozen if isinstance(frozen, dict) else {} + raise Frozen( + f"case reported to {details.get('by', 'a destination')} at " + f"{details.get('at', 'an unknown time')}; bodies cannot be " + f"regenerated. Edit a body by hand if it must change." + ) + + if modified and not force: + raise Modified( + "bodies edited since generation: " + ", ".join(modified) + + ". Re-run with --force to discard those edits; a timestamped " + "backup is kept." + ) diff --git a/tests/test_report.py b/tests/test_report.py index 6a340c9..b36252c 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,6 +1,7 @@ import copy import email import email.policy +import hashlib import json import unittest @@ -1596,5 +1597,114 @@ class DocumentIdentity(unittest.TestCase): self.assertEqual(addresses[0].display_name, name) +class Freeze(unittest.TestCase): + """A case that has reached a desk is an evidence record, not a draft.""" + + def _frozen(self, marker): + manifest = copy.deepcopy(MANIFEST) + manifest["frozen"] = marker + return manifest + + def test_a_frozen_case_refuses(self): + manifest = self._frozen({"at": "2026-09-07T10:00:00Z", + "by": "abusedb"}) + with self.assertRaises(report.Frozen) as caught: + report.check_regenerable(manifest, modified=[]) + self.assertIn("abusedb", str(caught.exception)) + + def test_a_frozen_case_refuses_even_when_forced(self): + manifest = self._frozen({"at": "2026-09-07T10:00:00Z", + "by": "abusedb"}) + with self.assertRaises(report.Frozen): + report.check_regenerable(manifest, modified=[], force=True) + + def test_a_frozen_case_refuses_before_the_modified_check(self): + """Frozen wins over force for EVERY shape of the second argument. + + The two rules are not independent: an edited body on a frozen case + must still refuse, and must refuse as Frozen rather than as + Modified, because Modified is the one --force can clear. + """ + manifest = self._frozen({"at": "2026-09-07T10:00:00Z", + "by": "abusedb"}) + with self.assertRaises(report.Frozen): + report.check_regenerable(manifest, modified=["email-1"]) + with self.assertRaises(report.Frozen): + report.check_regenerable(manifest, modified=["email-1"], + force=True) + + def test_a_present_but_empty_marker_still_freezes(self): + """Present means frozen. The marker is write-once and monotonic, so + a shape that is present and says nothing useful is a half-written or + corrupted record, and the safe direction is to refuse. Truthiness is + the wrong test: `{}` is present and falsy, and treating it as absent + silently unfreezes a case a desk may already hold. + """ + for marker in ({}, "", 0, []): + with self.subTest(marker=marker): + with self.assertRaises(report.Frozen): + report.check_regenerable(self._frozen(marker), + modified=[], force=True) + + def test_a_malformed_marker_refuses_rather_than_crashing(self): + """A marker that is not a mapping is still a refusal, not an + AttributeError. cli turns Frozen into a message and a traceback into + a bug report. + """ + for marker in ("2026-09-07T10:00:00Z", 1, ["abusedb"]): + with self.subTest(marker=marker): + with self.assertRaises(report.Frozen): + report.check_regenerable(self._frozen(marker), + modified=[]) + + def test_an_explicit_null_marker_is_absent(self): + """JSON has no way to omit a key it wrote as null, and null is the + one present value that unambiguously carries no record. + """ + manifest = self._frozen(None) + report.check_regenerable(manifest, modified=[]) + + def test_a_modified_body_refuses_without_force(self): + with self.assertRaises(report.Modified) as caught: + report.check_regenerable(MANIFEST, modified=["email-1"]) + self.assertIn("email-1", str(caught.exception)) + + def test_a_modified_body_is_allowed_with_force(self): + report.check_regenerable(MANIFEST, modified=["email-1"], force=True) + + def test_an_untouched_case_regenerates(self): + report.check_regenerable(MANIFEST, modified=[]) + + def test_it_does_not_mutate_the_manifest(self): + """It is a check, not a step. case.py is the only writer.""" + manifest = copy.deepcopy(MANIFEST) + before = copy.deepcopy(manifest) + report.check_regenerable(manifest, modified=[]) + self.assertEqual(manifest, before) + + +class Hashes(unittest.TestCase): + def test_the_hash_detects_a_changed_body(self): + first = report.body_hash("one") + self.assertNotEqual(first, report.body_hash("two")) + self.assertEqual(first, report.body_hash("one")) + + def test_it_is_sha256_of_the_utf8_bytes(self): + """The encoding is pinned, not incidental: the hash is compared + against a body read back off disk, and for a sent destination it is + the record of what was disclosed. + """ + text = "a body with a non-ascii org name: Örg\n" + self.assertEqual( + report.body_hash(text), + hashlib.sha256(text.encode("utf-8")).hexdigest(), + ) + + def test_it_hashes_a_real_generated_body(self): + destination = report.email_destinations(MANIFEST["contacts"])[0] + text = report.build(MANIFEST, destination, IDENTITY) + self.assertEqual(len(report.body_hash(text)), 64) + + if __name__ == "__main__": unittest.main() |
