ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_identity.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Durable two-part identity for suppression inventory rows.
4
5Line and column are display coordinates, not identity. Every row carries:
6
7* ``site_id`` -- a full SHA-256 naming the occurrence. It hashes the path,
8 family, tool, rule, directive, a line-normalized scope, the normalized
9 suppressed construct (the source line's collapsed text with an attached
10 rationale removed, or structural evidence for repository-bound rows), and a
11 deterministic ordinal
12 that separates otherwise identical repeated constructs. Inserting lines
13 above a site or re-indenting it does not change its ``site_id``; changing
14 the construct does.
15* ``binding_sha256`` -- a full SHA-256 over the canonical JSON of everything
16 a reviewer approved: path, family, tool, rule, directive, raw scope,
17 reason, provenance, owner, concerns, normalized evidence, match count,
18 recommendation, and the anchor. Any reason, target, rule, scope, owner,
19 evidence, count, or construct change invalidates the binding while the
20 site keeps its name.
21"""
22
23from __future__ import annotations
24
25import hashlib
26import json
27import re
28from dataclasses import replace
29from pathlib import Path
30
31from suppression_model import Finding, Inventory, Suppression
32
33IDENTITY_SCHEMA_VERSION = "2-durable-site-identity"
34_LINE_REF_RE = re.compile(r"^decision-line:\d+$")
35_LINE_REF_SUFFIX_RE = re.compile(
36 r"^(?P<head>decision-line):\d+$|^(?P<blob>blob):sha256:[0-9a-f]{64}$"
37)
38_WS_RE = re.compile(r"\s+")
39STRUCTURAL_PROVENANCE_PREFIXES = (
40 "ratchet-baseline",
41 "module-ast-authority",
42 "checker-control-plane",
43 "workflow-run-step",
44 "generator",
45 "vendor-boundary",
46)
47
48
49def _normalized_scope(scope: str) -> str:
50 """Strip line references and content digests from a scope label.
51
52 The stripped detail stays in the binding: a moved decision line keeps its
53 site while a changed vendored blob breaks its reviewed binding.
54 """
55 match = _LINE_REF_SUFFIX_RE.match(scope)
56 if match is None:
57 return scope
58 return match.group("head") or match.group("blob")
59
60
61def _normalized_evidence(evidence: tuple[str, ...]) -> tuple[str, ...]:
62 """Drop display-derived line references from structural evidence."""
63 return tuple(item for item in evidence if not _LINE_REF_RE.match(item))
64
65
66def _structural_anchor(row: Suppression) -> str | None:
67 """Return a structural anchor for rows not located by a source line."""
68 if row.provenance.split(":")[0] in STRUCTURAL_PROVENANCE_PREFIXES:
69 parts = _normalized_evidence(row.evidence)
70 return "|".join(parts) if parts else _normalized_scope(row.scope)
71 return None
72
73
74def _line_anchor(row: Suppression, lines: list[str]) -> str:
75 """Return the construct without its review rationale."""
76 if not 1 <= row.line <= len(lines):
77 return ""
78 source = lines[row.line - 1]
79 if row.reason:
80 reason_at = source.rfind(row.reason, max(row.column - 1, 0))
81 if reason_at >= 0:
82 source = source[:reason_at].rstrip()
83 return _WS_RE.sub(" ", source).strip()
84
85
86def compute_anchor(row: Suppression, lines: list[str]) -> str:
87 """Return the normalized suppressed construct for one row."""
88 structural = _structural_anchor(row)
89 if structural is not None:
90 return structural
91 return _line_anchor(row, lines)
92
93
94def _site_payload(row: Suppression, anchor: str, ordinal: int) -> bytes:
95 """Serialize the durable occurrence identity inputs."""
96 fields = (
97 IDENTITY_SCHEMA_VERSION,
98 row.path,
99 row.family,
100 row.tool,
101 row.rule,
102 row.directive,
103 _normalized_scope(row.scope),
104 anchor,
105 str(ordinal),
106 )
107 return "\0".join(fields).encode("utf-8")
108
109
110def binding_payload(row: Suppression, anchor: str) -> bytes:
111 """Serialize the exact reviewed content of one row."""
112 payload = {
113 "anchor": anchor,
114 "concerns": sorted(row.concerns),
115 "directive": row.directive,
116 "evidence": list(_normalized_evidence(row.evidence)),
117 "family": row.family,
118 "match_count": row.match_count,
119 "owner": row.owner,
120 "path": row.path,
121 "provenance": row.provenance,
122 "reason": row.reason,
123 "recommendation": row.recommendation,
124 "rule": row.rule,
125 "scope": row.scope,
126 "tool": row.tool,
127 }
128 return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
129
130
131def assign_identities(inventory: Inventory, root: Path) -> None:
132 """Attach ``site_id`` and ``binding_sha256`` to every inventory row."""
133 text_cache: dict[str, list[str]] = {}
134 anchored: list[tuple[Suppression, str]] = []
135 for row in inventory.suppressions:
136 if row.path not in text_cache:
137 try:
138 text_cache[row.path] = (
139 (root / row.path).read_text(encoding="utf-8", errors="replace").splitlines()
140 )
141 except OSError:
142 text_cache[row.path] = []
143 anchored.append((row, compute_anchor(row, text_cache[row.path])))
144 groups: dict[bytes, list[int]] = {}
145 for index, (row, anchor) in enumerate(anchored):
146 groups.setdefault(_site_payload(row, anchor, 0), []).append(index)
147 site_ids: dict[int, str] = {}
148 for members in groups.values():
149 members.sort(key=lambda index: (anchored[index][0].line, anchored[index][0].column))
150 for ordinal, index in enumerate(members):
151 row, anchor = anchored[index]
152 site_ids[index] = hashlib.sha256(_site_payload(row, anchor, ordinal)).hexdigest()
153 seen: dict[str, int] = {}
154 for index, (row, anchor) in enumerate(anchored):
155 site_id = site_ids[index]
156 if site_id in seen:
157 inventory.findings.append(
158 Finding(
159 "duplicate-site-identity",
160 f"site {site_id} names two rows",
161 row.path,
162 row.line,
163 )
164 )
165 seen[site_id] = index
166 binding = hashlib.sha256(binding_payload(row, anchor)).hexdigest()
167 inventory.suppressions[index] = replace(
168 row, site_id=site_id, binding_sha256=binding, anchor=anchor
169 )