commitguardian 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Policy models."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
|
|
7
|
+
from commitguard.core.decision import Action
|
|
8
|
+
from commitguard.security.validation import validate_identifier
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Policy(BaseModel):
|
|
12
|
+
"""The effective policy for a single rule ID."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
action: Action
|
|
19
|
+
description: str = Field(min_length=1)
|
|
20
|
+
|
|
21
|
+
@field_validator("id")
|
|
22
|
+
@classmethod
|
|
23
|
+
def _validate_id(cls, value: str) -> str:
|
|
24
|
+
return validate_identifier(value, kind="policy id")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PolicySet(Mapping[str, Policy]):
|
|
28
|
+
"""An immutable mapping of rule ID to effective :class:`Policy`."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, policies: Mapping[str, Policy]) -> None:
|
|
31
|
+
for key, policy in policies.items():
|
|
32
|
+
if key != policy.id:
|
|
33
|
+
raise ValueError(f"policy key {key!r} does not match policy id {policy.id!r}")
|
|
34
|
+
self._policies = dict(sorted(policies.items()))
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key: str) -> Policy:
|
|
37
|
+
return self._policies[key]
|
|
38
|
+
|
|
39
|
+
def __iter__(self) -> Iterator[str]:
|
|
40
|
+
return iter(self._policies)
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
return len(self._policies)
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"PolicySet({list(self._policies)})"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Provenance layer: who (or what) contributed a commit, and how we know.
|
|
2
|
+
|
|
3
|
+
This layer models and (eventually) analyses the *claims* a commit makes about
|
|
4
|
+
its origin: author, committer, co-authors, trailers and signatures. It is kept
|
|
5
|
+
separate from detectors so that provenance analysis can grow (signature
|
|
6
|
+
verification, identity correlation) without detectors re-implementing parsing.
|
|
7
|
+
|
|
8
|
+
Nothing in this package performs I/O or talks to Git.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Contributor identities.
|
|
2
|
+
|
|
3
|
+
An identity is a *claimed* ``name <email>`` pair. Git performs no verification
|
|
4
|
+
of these values, so they are untrusted evidence, never proof of authorship.
|
|
5
|
+
|
|
6
|
+
Two models exist on purpose:
|
|
7
|
+
|
|
8
|
+
* :class:`Identity` - an author/committer as recorded in a commit object,
|
|
9
|
+
where Git guarantees both fields exist (they may still be empty or odd);
|
|
10
|
+
* :class:`ParsedIdentity` - the best-effort parse of free text such as a
|
|
11
|
+
``Co-authored-by`` value, where either part may be missing or malformed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
|
|
18
|
+
from commitguard.provenance.normalization import normalize_email
|
|
19
|
+
|
|
20
|
+
GITHUB_NOREPLY_DOMAIN = "users.noreply.github.com"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Identity(BaseModel):
|
|
24
|
+
"""A Git identity recorded in a commit (author or committer)."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
email: str
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"{self.name} <{self.email}>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class IdentityIssue(StrEnum):
|
|
36
|
+
"""Why a free-text identity is not a well-formed ``Name <email>``."""
|
|
37
|
+
|
|
38
|
+
EMPTY = "empty"
|
|
39
|
+
MISSING_NAME = "missing_name"
|
|
40
|
+
MISSING_EMAIL = "missing_email"
|
|
41
|
+
MISSING_BRACKETS = "missing_brackets"
|
|
42
|
+
UNBALANCED_BRACKETS = "unbalanced_brackets"
|
|
43
|
+
INVALID_EMAIL = "invalid_email"
|
|
44
|
+
TRAILING_CONTENT = "trailing_content"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParsedIdentity(BaseModel):
|
|
48
|
+
"""Best-effort parse of an identity string. Never raises on bad input."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
51
|
+
|
|
52
|
+
name: str | None
|
|
53
|
+
email: str | None
|
|
54
|
+
issues: tuple[IdentityIssue, ...] = ()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def well_formed(self) -> bool:
|
|
58
|
+
return not self.issues
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
if self.email is None:
|
|
62
|
+
return self.name or ""
|
|
63
|
+
return f"{self.name or ''} <{self.email}>".strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_plausible_email(value: str) -> bool:
|
|
67
|
+
"""Structural email check: one ``@``, non-empty local part and dotted domain.
|
|
68
|
+
|
|
69
|
+
Intentionally not RFC 5322: the goal is to classify evidence, not to
|
|
70
|
+
validate deliverability.
|
|
71
|
+
"""
|
|
72
|
+
local, sep, domain = value.rpartition("@")
|
|
73
|
+
return (
|
|
74
|
+
bool(sep)
|
|
75
|
+
and bool(local)
|
|
76
|
+
and "@" not in local
|
|
77
|
+
and "." in domain.strip(".")
|
|
78
|
+
and not any(ch.isspace() or ch in "<>" for ch in value)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_identity(value: str) -> ParsedIdentity:
|
|
83
|
+
"""Parse ``Name <email>`` leniently, recording every deviation as an issue.
|
|
84
|
+
|
|
85
|
+
Examples::
|
|
86
|
+
|
|
87
|
+
"Claude <noreply@anthropic.com>" -> name, email, no issues
|
|
88
|
+
"Claude" -> name only, MISSING_EMAIL
|
|
89
|
+
"Claude noreply@anthropic.com" -> name, email, MISSING_BRACKETS
|
|
90
|
+
"<invalid>" -> email "invalid", MISSING_NAME, INVALID_EMAIL
|
|
91
|
+
"Claude <>" -> name only, MISSING_EMAIL
|
|
92
|
+
"""
|
|
93
|
+
text = value.strip()
|
|
94
|
+
if not text:
|
|
95
|
+
return ParsedIdentity(name=None, email=None, issues=(IdentityIssue.EMPTY,))
|
|
96
|
+
|
|
97
|
+
issues: list[IdentityIssue] = []
|
|
98
|
+
open_index = text.find("<")
|
|
99
|
+
close_index = text.find(">", open_index + 1) if open_index >= 0 else -1
|
|
100
|
+
|
|
101
|
+
if open_index >= 0 and close_index > open_index:
|
|
102
|
+
name = text[:open_index].strip() or None
|
|
103
|
+
email = text[open_index + 1 : close_index].strip() or None
|
|
104
|
+
trailing = text[close_index + 1 :].strip()
|
|
105
|
+
if trailing:
|
|
106
|
+
issues.append(IdentityIssue.TRAILING_CONTENT)
|
|
107
|
+
if name is not None and (">" in name or "<" in name):
|
|
108
|
+
issues.append(IdentityIssue.UNBALANCED_BRACKETS)
|
|
109
|
+
elif open_index >= 0 or ">" in text:
|
|
110
|
+
issues.append(IdentityIssue.UNBALANCED_BRACKETS)
|
|
111
|
+
stripped = text.replace("<", " ").replace(">", " ")
|
|
112
|
+
name, email = _split_bare_email(stripped)
|
|
113
|
+
else:
|
|
114
|
+
name, email = _split_bare_email(text)
|
|
115
|
+
if email is not None:
|
|
116
|
+
issues.append(IdentityIssue.MISSING_BRACKETS)
|
|
117
|
+
|
|
118
|
+
if name is None:
|
|
119
|
+
issues.append(IdentityIssue.MISSING_NAME)
|
|
120
|
+
if email is None:
|
|
121
|
+
issues.append(IdentityIssue.MISSING_EMAIL)
|
|
122
|
+
elif not is_plausible_email(email):
|
|
123
|
+
issues.append(IdentityIssue.INVALID_EMAIL)
|
|
124
|
+
|
|
125
|
+
return ParsedIdentity(name=name, email=email, issues=tuple(dict.fromkeys(issues)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _split_bare_email(text: str) -> tuple[str | None, str | None]:
|
|
129
|
+
"""Split ``Name user@host`` on the last whitespace token containing ``@``."""
|
|
130
|
+
tokens = text.split()
|
|
131
|
+
for index in range(len(tokens) - 1, -1, -1):
|
|
132
|
+
if "@" in tokens[index]:
|
|
133
|
+
name = " ".join(tokens[:index] + tokens[index + 1 :]) or None
|
|
134
|
+
return name, tokens[index]
|
|
135
|
+
return (" ".join(tokens) or None), None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def github_login(email: str) -> str | None:
|
|
139
|
+
"""Return the GitHub login from a ``[id+]login@users.noreply.github.com`` address."""
|
|
140
|
+
local, sep, domain = normalize_email(email).rpartition("@")
|
|
141
|
+
if not sep or domain != GITHUB_NOREPLY_DOMAIN or not local:
|
|
142
|
+
return None
|
|
143
|
+
user_id, plus, login = local.partition("+")
|
|
144
|
+
if plus:
|
|
145
|
+
return login if user_id.isdigit() and login else None
|
|
146
|
+
return local
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Committer provenance analysis.
|
|
2
|
+
|
|
3
|
+
The committer is the identity that *created the commit object*, which may
|
|
4
|
+
differ from the author (rebases, cherry-picks, patches applied by maintainers,
|
|
5
|
+
or web UIs such as GitHub's ``web-flow``). Automation frequently reveals itself
|
|
6
|
+
here rather than in the author field.
|
|
7
|
+
|
|
8
|
+
TODO(phase-5): analyse author/committer relationships, e.g.
|
|
9
|
+
* committer is a known automation identity while author is a human;
|
|
10
|
+
* committer identity differs from the signing key identity;
|
|
11
|
+
* implausible author/committer timestamp gaps.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from commitguard.provenance.author import Identity
|
|
15
|
+
|
|
16
|
+
__all__ = ["Identity"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Deterministic normalisation of untrusted identity text for *matching*.
|
|
2
|
+
|
|
3
|
+
Normalised values are only ever used as comparison keys. Evidence shown to
|
|
4
|
+
users always keeps the original (sanitised) text.
|
|
5
|
+
|
|
6
|
+
What is normalised, and why:
|
|
7
|
+
|
|
8
|
+
* Unicode NFKC - fullwidth / mathematical bold letters fold to ASCII;
|
|
9
|
+
* control (Cc) and format (Cf) characters removed - zero-width spaces, bidi
|
|
10
|
+
overrides and escape codes cannot split a name to evade matching;
|
|
11
|
+
* every other Unicode default-ignorable code point removed - variation
|
|
12
|
+
selectors, U+034F COMBINING GRAPHEME JOINER and the Hangul fillers render as
|
|
13
|
+
nothing too (found by property-based fuzzing: ``Clau\u034fde`` and
|
|
14
|
+
``Co-authored\ufe0f-by`` evaded detection);
|
|
15
|
+
* case folding and whitespace collapsing;
|
|
16
|
+
* a *small, explicit* map of Cyrillic/Greek letters that are visually
|
|
17
|
+
identical to Latin letters (``Claude`` spelled with a Cyrillic ``a``, U+0430).
|
|
18
|
+
|
|
19
|
+
What is deliberately **not** done: removing punctuation, stemming, substring
|
|
20
|
+
or fuzzy matching. ``Claude`` must never match ``Claudette``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import unicodedata
|
|
24
|
+
|
|
25
|
+
# Visually identical to Latin letters in common fonts. Lowercase only: input is
|
|
26
|
+
# case-folded first. Keep this list conservative; every entry is a potential
|
|
27
|
+
# false-positive source for legitimate non-Latin names.
|
|
28
|
+
# Code points (not literal characters) so the source contains no look-alikes.
|
|
29
|
+
_HOMOGLYPHS: dict[int, str] = {
|
|
30
|
+
0x0430: "a", # CYRILLIC SMALL LETTER A
|
|
31
|
+
0x0432: "b", # CYRILLIC SMALL LETTER VE
|
|
32
|
+
0x0435: "e", # CYRILLIC SMALL LETTER IE
|
|
33
|
+
0x04BB: "h", # CYRILLIC SMALL LETTER SHHA
|
|
34
|
+
0x0456: "i", # CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
|
|
35
|
+
0x0458: "j", # CYRILLIC SMALL LETTER JE
|
|
36
|
+
0x043A: "k", # CYRILLIC SMALL LETTER KA
|
|
37
|
+
0x043C: "m", # CYRILLIC SMALL LETTER EM
|
|
38
|
+
0x043E: "o", # CYRILLIC SMALL LETTER O
|
|
39
|
+
0x0440: "p", # CYRILLIC SMALL LETTER ER
|
|
40
|
+
0x0441: "c", # CYRILLIC SMALL LETTER ES
|
|
41
|
+
0x0455: "s", # CYRILLIC SMALL LETTER DZE
|
|
42
|
+
0x0442: "t", # CYRILLIC SMALL LETTER TE
|
|
43
|
+
0x0443: "y", # CYRILLIC SMALL LETTER U
|
|
44
|
+
0x0445: "x", # CYRILLIC SMALL LETTER HA
|
|
45
|
+
0x0501: "d", # CYRILLIC SMALL LETTER KOMI DE
|
|
46
|
+
0x051B: "q", # CYRILLIC SMALL LETTER QA
|
|
47
|
+
0x051D: "w", # CYRILLIC SMALL LETTER WE
|
|
48
|
+
0x03B1: "a", # GREEK SMALL LETTER ALPHA
|
|
49
|
+
0x03B5: "e", # GREEK SMALL LETTER EPSILON
|
|
50
|
+
0x03B9: "i", # GREEK SMALL LETTER IOTA
|
|
51
|
+
0x03BA: "k", # GREEK SMALL LETTER KAPPA
|
|
52
|
+
0x03BD: "v", # GREEK SMALL LETTER NU
|
|
53
|
+
0x03BF: "o", # GREEK SMALL LETTER OMICRON
|
|
54
|
+
0x03C1: "p", # GREEK SMALL LETTER RHO
|
|
55
|
+
0x03C4: "t", # GREEK SMALL LETTER TAU
|
|
56
|
+
0x03C5: "u", # GREEK SMALL LETTER UPSILON
|
|
57
|
+
0x03C7: "x", # GREEK SMALL LETTER CHI
|
|
58
|
+
0x0131: "i", # LATIN SMALL LETTER DOTLESS I
|
|
59
|
+
0x0237: "j", # LATIN SMALL LETTER DOTLESS J
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ASCII control characters that are not whitespace: the only Cc/Cf characters ASCII has.
|
|
64
|
+
_ASCII_INVISIBLE = {code: None for code in (*range(0x00, 0x20), 0x7F) if not chr(code).isspace()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Unicode Default_Ignorable_Code_Point (DerivedCoreProperties.txt) that are not Cc or Cf:
|
|
68
|
+
# they render as nothing in normal text, like zero-width spaces, but NFKC keeps them.
|
|
69
|
+
_DEFAULT_IGNORABLE_RANGES = (
|
|
70
|
+
(0x034F, 0x034F), # COMBINING GRAPHEME JOINER
|
|
71
|
+
(0x115F, 0x1160), # HANGUL CHOSEONG / JUNGSEONG FILLER
|
|
72
|
+
(0x17B4, 0x17B5), # KHMER VOWEL INHERENT AQ / AA
|
|
73
|
+
(0x180B, 0x180F), # MONGOLIAN FREE VARIATION SELECTORS, VOWEL SEPARATOR
|
|
74
|
+
(0x2065, 0x2065), # unassigned
|
|
75
|
+
(0x3164, 0x3164), # HANGUL FILLER
|
|
76
|
+
(0xFE00, 0xFE0F), # VARIATION SELECTOR-1..16
|
|
77
|
+
(0xFFA0, 0xFFA0), # HALFWIDTH HANGUL FILLER
|
|
78
|
+
(0xFFF0, 0xFFF8), # unassigned
|
|
79
|
+
(0xE0000, 0xE0FFF), # tags, VARIATION SELECTOR-17..256, unassigned
|
|
80
|
+
)
|
|
81
|
+
_DEFAULT_IGNORABLE = frozenset(
|
|
82
|
+
code for first, last in _DEFAULT_IGNORABLE_RANGES for code in range(first, last + 1)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _visible(ch: str) -> bool:
|
|
87
|
+
if ch.isspace():
|
|
88
|
+
return True
|
|
89
|
+
return unicodedata.category(ch) not in ("Cc", "Cf") and ord(ch) not in _DEFAULT_IGNORABLE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _strip_invisible(text: str) -> str:
|
|
93
|
+
# Whitespace controls (tab, newline, ...) are kept so they still separate words.
|
|
94
|
+
if text.isascii():
|
|
95
|
+
# Fast path, same result: ASCII has no format (Cf) or default-ignorable characters.
|
|
96
|
+
# Measured by the performance benchmark: per-character categorisation dominated
|
|
97
|
+
# large messages.
|
|
98
|
+
return text if text.isprintable() else text.translate(_ASCII_INVISIBLE)
|
|
99
|
+
return "".join(ch for ch in text if _visible(ch))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalize_text(text: str) -> str:
|
|
103
|
+
"""Return a case-, width- and whitespace-insensitive comparison key."""
|
|
104
|
+
if text.isascii():
|
|
105
|
+
text = _strip_invisible(text) # NFKC leaves ASCII unchanged
|
|
106
|
+
else:
|
|
107
|
+
text = _strip_invisible(unicodedata.normalize("NFKC", _strip_invisible(text)))
|
|
108
|
+
text = text.casefold().translate(_HOMOGLYPHS)
|
|
109
|
+
return " ".join(text.split())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_latin_lookalike(char: str) -> bool:
|
|
113
|
+
"""True if ``char`` is a non-ASCII look-alike that :func:`normalize_text` folds to Latin."""
|
|
114
|
+
folded = char.casefold()
|
|
115
|
+
return not char.isascii() and folded.translate(_HOMOGLYPHS) != folded
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def normalize_name(name: str) -> str:
|
|
119
|
+
"""Normalise a person/agent display name for exact alias comparison."""
|
|
120
|
+
return normalize_text(name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def name_tokens(name: str) -> tuple[str, ...]:
|
|
124
|
+
"""Whitespace tokens of a normalised name (for leading-token prefix rules)."""
|
|
125
|
+
return tuple(normalize_name(name).split())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalize_email(email: str) -> str:
|
|
129
|
+
"""Normalise an email address for comparison.
|
|
130
|
+
|
|
131
|
+
Case-folded as a whole (local parts are case-sensitive in theory, never in
|
|
132
|
+
practice for the providers relevant here); a trailing dot on the domain is
|
|
133
|
+
removed. No other rewriting (e.g. dot removal) is performed.
|
|
134
|
+
"""
|
|
135
|
+
email = normalize_text(email).replace(" ", "")
|
|
136
|
+
local, sep, domain = email.rpartition("@")
|
|
137
|
+
if not sep:
|
|
138
|
+
return email
|
|
139
|
+
return f"{local}@{domain.rstrip('.')}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_domain(domain: str) -> str:
|
|
143
|
+
return normalize_text(domain).replace(" ", "").strip(".")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normalize_trailer_key(key: str) -> str:
|
|
147
|
+
"""Canonical trailer key: ``Co_Authored By`` -> ``co-authored-by``."""
|
|
148
|
+
text = normalize_text(key).replace("_", " ").replace("-", " ")
|
|
149
|
+
return "-".join(text.split())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def uses_disguising_characters(text: str) -> bool:
|
|
153
|
+
"""True if matching ``text`` relied on folding look-alike or invisible characters.
|
|
154
|
+
|
|
155
|
+
Plain case and whitespace differences do not count; NFKC compatibility
|
|
156
|
+
forms, control/format and other default-ignorable characters and homoglyphs do.
|
|
157
|
+
"""
|
|
158
|
+
return normalize_text(text) != " ".join(text.casefold().split())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Commit signature provenance.
|
|
2
|
+
|
|
3
|
+
TODO(phase-5): read signature status via Git (``%G?``, ``%GK``, ``%GS``) for
|
|
4
|
+
GPG, SSH and X.509 signatures, and let policies require verified signatures.
|
|
5
|
+
Verification depends on the local keyring / allowed-signers configuration,
|
|
6
|
+
which is itself a trust decision and must be explicit in configuration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SignatureStatus(StrEnum):
|
|
15
|
+
"""Signature state of a commit, mirroring Git's ``%G?`` codes."""
|
|
16
|
+
|
|
17
|
+
GOOD = "good" # G
|
|
18
|
+
BAD = "bad" # B
|
|
19
|
+
UNKNOWN_VALIDITY = "unknown_validity" # U
|
|
20
|
+
EXPIRED = "expired" # X
|
|
21
|
+
EXPIRED_KEY = "expired_key" # Y
|
|
22
|
+
REVOKED_KEY = "revoked_key" # R
|
|
23
|
+
CANNOT_CHECK = "cannot_check" # E
|
|
24
|
+
UNSIGNED = "unsigned" # N
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SignatureInfo(BaseModel):
|
|
28
|
+
"""Signature metadata for a commit. Not populated yet (security intelligence phase)."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
31
|
+
|
|
32
|
+
status: SignatureStatus
|
|
33
|
+
key_id: str | None = None
|
|
34
|
+
signer: str | None = None
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Commit message trailer parsing.
|
|
2
|
+
|
|
3
|
+
Trailers are ``Key: value`` lines, conventionally in the last paragraph of a
|
|
4
|
+
commit message. They are the primary place AI agents record attribution::
|
|
5
|
+
|
|
6
|
+
feat: implement authentication
|
|
7
|
+
|
|
8
|
+
Co-authored-by: Claude <noreply@anthropic.com>
|
|
9
|
+
|
|
10
|
+
Parsing is lenient and never raises. Because attribution can be hidden or
|
|
11
|
+
mangled deliberately, the parser also records:
|
|
12
|
+
|
|
13
|
+
* trailer-formatted lines **outside** the final paragraph
|
|
14
|
+
(``in_trailer_block=False``) - Git ignores them, a reader does not;
|
|
15
|
+
* ``*-by`` keys written without a colon (``Co-authored-by Claude ...``) or with
|
|
16
|
+
spaces/underscores/invisible characters in the key;
|
|
17
|
+
* indented lines that are themselves trailers, instead of folding them into
|
|
18
|
+
the previous value as a continuation (which would hide them);
|
|
19
|
+
* every Unicode line separator (``\\u2028``, ``\\r``...), not only ``\\n``;
|
|
20
|
+
* keys preceded by symbols or punctuation (``\\ufffdCo-authored-by:``,
|
|
21
|
+
``> Co-authored-by:``, ``• Co-authored-by:``): the prefix is ignored and the
|
|
22
|
+
trailer is recorded with ``LEADING_CHARACTERS`` (found by the detection
|
|
23
|
+
benchmark: a replacement character from malformed UTF-8 hid attribution);
|
|
24
|
+
the same applies to non-ASCII letters and numbers (``\\u32acCo-authored-by:``,
|
|
25
|
+
found by property-based fuzzing), because keys are ASCII.
|
|
26
|
+
|
|
27
|
+
Work is linear in the message size and bounded to :data:`MAX_TRAILERS`
|
|
28
|
+
trailers; anything beyond sets ``truncated`` so callers can fail closed.
|
|
29
|
+
No regular expressions are used.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import unicodedata
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
37
|
+
|
|
38
|
+
from commitguard.provenance.author import ParsedIdentity, parse_identity
|
|
39
|
+
from commitguard.provenance.normalization import is_latin_lookalike, normalize_trailer_key
|
|
40
|
+
|
|
41
|
+
COAUTHOR_TRAILER_KEY = "co-authored-by"
|
|
42
|
+
MAX_TRAILERS = 1000
|
|
43
|
+
MAX_KEY_LENGTH = 64
|
|
44
|
+
#: At most this many leading symbol/punctuation characters are skipped before a key.
|
|
45
|
+
MAX_LEADING_CHARACTERS = 16
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TrailerIssue(StrEnum):
|
|
49
|
+
"""Structural problems with a trailer line."""
|
|
50
|
+
|
|
51
|
+
MISSING_SEPARATOR = "missing_separator"
|
|
52
|
+
NONSTANDARD_KEY = "nonstandard_key"
|
|
53
|
+
EMPTY_VALUE = "empty_value"
|
|
54
|
+
LEADING_CHARACTERS = "leading_characters"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Trailer(BaseModel):
|
|
58
|
+
"""A trailer (or trailer-like line) found in a commit message."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
61
|
+
|
|
62
|
+
key: str = Field(description="Key as written (after Unicode NFKC)")
|
|
63
|
+
value: str
|
|
64
|
+
raw: str = Field(description="Original line text, for evidence")
|
|
65
|
+
line_number: int = Field(ge=1, description="1-based line number in the message")
|
|
66
|
+
in_trailer_block: bool = Field(description="True if in Git's trailer block (final paragraph)")
|
|
67
|
+
issues: tuple[TrailerIssue, ...] = ()
|
|
68
|
+
identity: ParsedIdentity
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def normalized_key(self) -> str:
|
|
72
|
+
return normalize_trailer_key(self.key)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def name(self) -> str | None:
|
|
76
|
+
return self.identity.name
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def email(self) -> str | None:
|
|
80
|
+
return self.identity.email
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def well_formed(self) -> bool:
|
|
84
|
+
return not self.issues
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ParsedTrailers(BaseModel):
|
|
88
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
89
|
+
|
|
90
|
+
trailers: tuple[Trailer, ...] = ()
|
|
91
|
+
truncated: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class _Draft:
|
|
96
|
+
key: str
|
|
97
|
+
value_parts: list[str]
|
|
98
|
+
raw_parts: list[str]
|
|
99
|
+
line_number: int
|
|
100
|
+
last_line: int
|
|
101
|
+
paragraph: int
|
|
102
|
+
issues: list[TrailerIssue] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_standard_key(key: str) -> bool:
|
|
106
|
+
return (
|
|
107
|
+
0 < len(key) <= MAX_KEY_LENGTH
|
|
108
|
+
and key[0].isascii()
|
|
109
|
+
and key[0].isalnum()
|
|
110
|
+
and all((ch.isascii() and ch.isalnum()) or ch == "-" for ch in key)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_by_key(key: str) -> bool:
|
|
115
|
+
"""A ``*-by`` key possibly written with spaces, underscores or invisible chars."""
|
|
116
|
+
if not 0 < len(key) <= MAX_KEY_LENGTH:
|
|
117
|
+
return False
|
|
118
|
+
normalized = normalize_trailer_key(key)
|
|
119
|
+
return (
|
|
120
|
+
normalized.endswith("-by")
|
|
121
|
+
and len(normalized) > 3
|
|
122
|
+
and all((ch.isascii() and ch.isalnum()) or ch == "-" for ch in normalized)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_ascii_alnum(char: str) -> bool:
|
|
127
|
+
return char.isascii() and char.isalnum()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _starts_key(char: str) -> bool:
|
|
131
|
+
# Look-alike letters are part of a disguised key (U+0421 in "Co-authored-by"), not a prefix.
|
|
132
|
+
return _is_ascii_alnum(char) or is_latin_lookalike(char)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _skip_leading(text: str) -> str:
|
|
136
|
+
start = 0
|
|
137
|
+
while start < len(text) and start < MAX_LEADING_CHARACTERS and not _starts_key(text[start]):
|
|
138
|
+
start += 1
|
|
139
|
+
return text[start:].strip()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_line(line: str) -> tuple[str, str, list[TrailerIssue]] | None:
|
|
143
|
+
text = unicodedata.normalize("NFKC", line).strip()
|
|
144
|
+
if not text:
|
|
145
|
+
return None
|
|
146
|
+
stripped = line.strip()
|
|
147
|
+
if _is_ascii_alnum(stripped[0]):
|
|
148
|
+
# The common case, including every well-formed trailer: no prefix to consider.
|
|
149
|
+
return _parse_text(text) if _is_ascii_alnum(text[0]) else None
|
|
150
|
+
# Characters before the key are not part of it: symbols and punctuation ("> ", "- ",
|
|
151
|
+
# U+FFFD) and non-ASCII letters or numbers (U+32AC, U+2460) must neither hide a
|
|
152
|
+
# trailer nor turn a quoted or listed line into a malformed key. Keys are ASCII, so the
|
|
153
|
+
# prefix is whatever precedes the first ASCII letter or digit, judged both after NFKC
|
|
154
|
+
# (U+32AC becomes a CJK ideograph) and before it (U+2460 becomes "1", U+24DE a plain
|
|
155
|
+
# "o"). When the readings disagree, the shortest key wins: it attributes the fewest
|
|
156
|
+
# prefix characters to the key.
|
|
157
|
+
readings = [
|
|
158
|
+
_skip_leading(text),
|
|
159
|
+
unicodedata.normalize("NFKC", _skip_leading(stripped)).strip(),
|
|
160
|
+
]
|
|
161
|
+
if _is_ascii_alnum(text[0]):
|
|
162
|
+
readings.append(text)
|
|
163
|
+
best: tuple[str, str, list[TrailerIssue]] | None = None
|
|
164
|
+
for candidate in dict.fromkeys(readings):
|
|
165
|
+
if not candidate or not _starts_key(candidate[0]):
|
|
166
|
+
continue
|
|
167
|
+
parsed = _parse_text(candidate)
|
|
168
|
+
if parsed is None or (best is not None and len(parsed[0]) >= len(best[0])):
|
|
169
|
+
continue
|
|
170
|
+
key, value, issues = parsed
|
|
171
|
+
prefixed = candidate != text
|
|
172
|
+
best = (key, value, [*issues, TrailerIssue.LEADING_CHARACTERS] if prefixed else issues)
|
|
173
|
+
return best
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_text(text: str) -> tuple[str, str, list[TrailerIssue]] | None:
|
|
177
|
+
|
|
178
|
+
colon = text.find(":")
|
|
179
|
+
if 0 < colon <= MAX_KEY_LENGTH + 8:
|
|
180
|
+
written_key = text[:colon]
|
|
181
|
+
key = written_key.rstrip()
|
|
182
|
+
value = text[colon + 1 :].strip()
|
|
183
|
+
if _is_standard_key(key) and not value.startswith("//"): # skip "https://..."
|
|
184
|
+
issues = [] if key == written_key else [TrailerIssue.NONSTANDARD_KEY]
|
|
185
|
+
return key, value, issues
|
|
186
|
+
if _is_by_key(key):
|
|
187
|
+
return key, value, [TrailerIssue.NONSTANDARD_KEY]
|
|
188
|
+
|
|
189
|
+
# "Co-authored-by Claude <...>" / "Co-authored-by=Claude"
|
|
190
|
+
head = text.split(None, 1)[0]
|
|
191
|
+
key, eq, after_eq = head.partition("=")
|
|
192
|
+
if _is_by_key(key):
|
|
193
|
+
rest = (after_eq + text[len(head) :]) if eq else text[len(head) :]
|
|
194
|
+
value = rest.strip().lstrip("=").strip()
|
|
195
|
+
if value:
|
|
196
|
+
return key, value, [TrailerIssue.MISSING_SEPARATOR]
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_trailers(message: str) -> ParsedTrailers:
|
|
201
|
+
"""Extract trailers from a raw commit message. Never raises."""
|
|
202
|
+
drafts: list[_Draft] = []
|
|
203
|
+
truncated = False
|
|
204
|
+
paragraph = 0
|
|
205
|
+
previous_blank = True
|
|
206
|
+
last_content_paragraph = 0
|
|
207
|
+
|
|
208
|
+
for line_number, line in enumerate(message.splitlines(), start=1):
|
|
209
|
+
if not line.strip():
|
|
210
|
+
if not previous_blank:
|
|
211
|
+
paragraph += 1
|
|
212
|
+
previous_blank = True
|
|
213
|
+
continue
|
|
214
|
+
previous_blank = False
|
|
215
|
+
last_content_paragraph = paragraph
|
|
216
|
+
|
|
217
|
+
parsed = _parse_line(line)
|
|
218
|
+
if parsed is not None and line_number == 1 and not _is_by_key(parsed[0]):
|
|
219
|
+
parsed = None # the subject ("feat: ...") is not a trailer
|
|
220
|
+
if parsed is None:
|
|
221
|
+
continuation = line[:1].isspace()
|
|
222
|
+
if continuation and drafts and drafts[-1].last_line == line_number - 1:
|
|
223
|
+
# Collected as parts and joined once: repeated string concatenation
|
|
224
|
+
# would be quadratic on messages with many continuation lines.
|
|
225
|
+
drafts[-1].value_parts.append(line.strip())
|
|
226
|
+
drafts[-1].raw_parts.append(line.strip())
|
|
227
|
+
drafts[-1].last_line = line_number
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if len(drafts) >= MAX_TRAILERS:
|
|
231
|
+
truncated = True
|
|
232
|
+
break
|
|
233
|
+
key, value, issues = parsed
|
|
234
|
+
drafts.append(
|
|
235
|
+
_Draft(key, [value], [line.strip()], line_number, line_number, paragraph, issues)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
trailers = []
|
|
239
|
+
for draft in drafts:
|
|
240
|
+
value = " ".join(part for part in draft.value_parts if part)
|
|
241
|
+
issues = list(draft.issues)
|
|
242
|
+
if not value:
|
|
243
|
+
issues.append(TrailerIssue.EMPTY_VALUE)
|
|
244
|
+
trailers.append(
|
|
245
|
+
Trailer(
|
|
246
|
+
key=draft.key,
|
|
247
|
+
value=value,
|
|
248
|
+
raw=" ".join(draft.raw_parts),
|
|
249
|
+
line_number=draft.line_number,
|
|
250
|
+
in_trailer_block=draft.paragraph == last_content_paragraph
|
|
251
|
+
and last_content_paragraph > 0,
|
|
252
|
+
issues=tuple(issues),
|
|
253
|
+
identity=parse_identity(value),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return ParsedTrailers(trailers=tuple(trailers), truncated=truncated)
|