aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 10:13:04 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 10:13:04 +0200
commit2cdd4dba93b26691a85cd0977294687d5afe0670 (patch)
tree6027db6b599b43138a7b1f81a08296f7e91cfa5e
parent57dc93c3069ccac583533f4f2d4f9cbc2720c73e (diff)
downloadabusectl-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.py72
-rw-r--r--tests/test_report.py110
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()