|
14 | 14 | from datetime import date |
15 | 15 | from email.message import Message |
16 | 16 | from email.parser import Parser |
| 17 | +from html.parser import HTMLParser |
17 | 18 | from pathlib import Path |
18 | 19 | from typing import cast |
19 | 20 | from urllib.parse import quote, unquote |
20 | 21 |
|
21 | | -from markdown_it import MarkdownIt |
22 | | - |
23 | 22 | if sys.version_info >= (3, 11): |
24 | 23 | import tomllib |
25 | 24 | else: |
|
134 | 133 | r")(?=$|[/?#>])" |
135 | 134 | ) |
136 | 135 | _HTTP_URL = re.compile(r"https?://[^\s<>)\]]+", re.IGNORECASE) |
137 | | -_URLISH_TOKEN = re.compile(r"https?:[^\s<>\"']+", re.IGNORECASE) |
138 | 136 | _RECOVERY_TAGS = {"0.1.0a1": "v0.1.0-alpha.1+recovery.1"} |
139 | 137 | _FULL_COMMIT = re.compile(r"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])", re.IGNORECASE) |
140 | 138 | _ACTIONS_PREFIX = f"{CANONICAL_REPOSITORY}/actions/runs/" |
141 | 139 | _ACTIONS_PATH = re.compile( |
142 | 140 | r"actions/runs/(?P<run>\d+)(?:/attempts/(?P<attempt>\d+))?", |
143 | 141 | re.IGNORECASE, |
144 | 142 | ) |
145 | | -_CANONICAL_ACTIONS_URL = re.compile( |
146 | | - rf"(?<![^\s<(\"']){re.escape(_ACTIONS_PREFIX)}(?P<run>[1-9]\d*)" |
| 143 | +_CANONICAL_ACTIONS_DESTINATION = re.compile( |
| 144 | + rf"{re.escape(_ACTIONS_PREFIX)}(?P<run>[1-9]\d*)" |
147 | 145 | r"(?:/attempts/(?P<attempt>[1-9]\d*))?" |
148 | | - r"(?=$|[\s<>\"')\]}]|[.,;:!?](?=$|\s))" |
149 | 146 | ) |
150 | | -_RAW_HTML_URL_ATTRIBUTE = re.compile( |
151 | | - r"(?is)\b(?:href|src)\s*=\s*(?P<quote>['\"])(?P<url>.*?)(?P=quote)" |
| 147 | +_RAW_ACTIONS_DESTINATION = re.compile( |
| 148 | + rf"(?<![^\s(])(?P<url>{re.escape(_ACTIONS_PREFIX)}[1-9]\d*" |
| 149 | + r"(?:/attempts/[1-9]\d*)?)" |
| 150 | + r"(?=$|[\s.,;:!?])" |
152 | 151 | ) |
153 | | - |
154 | | - |
155 | | -def _actions_path_has_canonical_url( |
156 | | - text: str, |
157 | | - path: re.Match[str], |
158 | | - canonical_matches: list[re.Match[str]], |
159 | | -) -> bool: |
160 | | - canonical = next( |
161 | | - ( |
162 | | - match |
163 | | - for match in canonical_matches |
164 | | - if match.start() <= path.start() and path.end() <= match.end() |
165 | | - ), |
166 | | - None, |
167 | | - ) |
168 | | - if canonical is None: |
169 | | - return False |
170 | | - |
171 | | - for link in _MARKDOWN_LINK.finditer(text): |
172 | | - if link.start("target") <= path.start() and path.end() <= link.end("target"): |
173 | | - return _CANONICAL_ACTIONS_URL.fullmatch(link.group("target")) is not None |
174 | | - |
175 | | - for attribute in _RAW_HTML_URL_ATTRIBUTE.finditer(text): |
176 | | - if attribute.start("url") <= path.start() and path.end() <= attribute.end("url"): |
177 | | - return _CANONICAL_ACTIONS_URL.fullmatch(attribute.group("url")) is not None |
178 | | - |
179 | | - containing_tokens = [ |
180 | | - match |
181 | | - for match in _URLISH_TOKEN.finditer(text) |
182 | | - if match.start() <= path.start() and path.end() <= match.end() |
183 | | - ] |
184 | | - if containing_tokens and min(match.start() for match in containing_tokens) < canonical.start(): |
185 | | - return False |
186 | | - |
187 | | - if canonical.start() == 0: |
188 | | - return True |
189 | | - boundary = text[canonical.start() - 1] |
190 | | - if boundary.isspace(): |
191 | | - return True |
192 | | - if boundary == "<": |
193 | | - return canonical.start() >= 2 and text[canonical.start() - 2].isspace() |
194 | | - if boundary == "(": |
195 | | - return canonical.start() == 1 or text[canonical.start() - 2].isspace() |
196 | | - return False |
197 | | - |
198 | | - |
199 | | -def _rendered_markdown_link_targets(text: str) -> list[str]: |
200 | | - targets: list[str] = [] |
201 | | - for token in MarkdownIt("commonmark", {"html": True}).parse(text): |
202 | | - for child in token.children or []: |
203 | | - if child.type != "link_open": |
204 | | - continue |
205 | | - target = child.attrGet("href") |
206 | | - if isinstance(target, str): |
207 | | - targets.append(target) |
208 | | - return targets |
209 | | - |
210 | | - |
211 | 152 | _WHEEL_DIGEST = re.compile( |
212 | 153 | r"\bwheel\s+sha256\b[^0-9a-f]{0,96}(?P<digest>[0-9a-f]{64})(?![0-9a-f])", |
213 | 154 | re.IGNORECASE | re.DOTALL, |
@@ -250,6 +191,24 @@ class CheckError(RuntimeError): |
250 | 191 | """Raised when release-candidate evidence does not satisfy a local gate.""" |
251 | 192 |
|
252 | 193 |
|
| 194 | +class _ActionsAnchorParser(HTMLParser): |
| 195 | + """Collect exact Actions destinations from rendered HTML anchors.""" |
| 196 | + |
| 197 | + def __init__(self) -> None: |
| 198 | + super().__init__(convert_charrefs=True) |
| 199 | + self.destinations: Counter[tuple[str, str | None]] = Counter() |
| 200 | + |
| 201 | + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: |
| 202 | + if tag.casefold() != "a": |
| 203 | + return |
| 204 | + for name, value in attrs: |
| 205 | + if name.casefold() != "href" or value is None: |
| 206 | + continue |
| 207 | + destination = _CANONICAL_ACTIONS_DESTINATION.fullmatch(value) |
| 208 | + if destination is not None: |
| 209 | + self.destinations[(destination.group("run"), destination.group("attempt"))] += 1 |
| 210 | + |
| 211 | + |
253 | 212 | @dataclass(frozen=True) |
254 | 213 | class ReleaseEvidenceIdentity: |
255 | 214 | """Machine-readable identity for one immutable historical release.""" |
@@ -747,53 +706,80 @@ def _canonical_actions_run_violations( |
747 | 706 | findings: list[tuple[int, str]] = [] |
748 | 707 | direct = unicodedata.normalize("NFKC", body) |
749 | 708 | variants = [direct] |
750 | | - for _ in range(8): |
| 709 | + converged = False |
| 710 | + for _ in range(len(direct) + 1): |
751 | 711 | previous = variants[-1] |
752 | 712 | decoded = html.unescape(previous) |
753 | 713 | decoded = unquote(decoded) |
754 | 714 | decoded = re.sub(r"\\([/\\.:?&=%#])", r"\1", decoded) |
755 | | - decoded = decoded.replace("\t", "").replace("\n", "").replace("\r", "") |
| 715 | + decoded = decoded.replace("\t", "") |
756 | 716 | decoded = re.sub( |
757 | 717 | r"(?i)(https?:[^\s<>)\]]*)\\([^\s<>)\]]*)", |
758 | 718 | lambda match: match.group(0).replace("\\", "/"), |
759 | 719 | decoded, |
760 | 720 | ) |
761 | 721 | if decoded == previous: |
| 722 | + converged = True |
762 | 723 | break |
763 | 724 | variants.append(decoded) |
764 | 725 |
|
765 | | - path_matches = list(_ACTIONS_PATH.finditer(direct)) |
766 | | - canonical_matches = list(_CANONICAL_ACTIONS_URL.finditer(direct)) |
767 | | - malformed = any( |
768 | | - not _actions_path_has_canonical_url(direct, path, canonical_matches) |
769 | | - for path in path_matches |
770 | | - ) |
771 | | - direct_paths = Counter((match.group("run"), match.group("attempt")) for match in path_matches) |
772 | 726 | variant_paths = [ |
773 | 727 | Counter( |
774 | | - (match.group("run"), match.group("attempt")) |
| 728 | + (match.group("run"), match.group("attempt") or None) |
775 | 729 | for match in _ACTIONS_PATH.finditer(variant) |
776 | 730 | ) |
777 | 731 | for variant in variants |
778 | 732 | ] |
| 733 | + normalized = variants[-1] |
| 734 | + rendered_destinations: Counter[tuple[str, str | None]] = Counter() |
| 735 | + # Keep module import dependency-free for the copied-checkout pre-sync bootstrap. |
| 736 | + from markdown_it import MarkdownIt |
| 737 | + |
| 738 | + parser = MarkdownIt("commonmark", {"html": True}) |
| 739 | + for token in parser.parse(normalized): |
| 740 | + for child in token.children or []: |
| 741 | + if child.type == "html_inline": |
| 742 | + html_parser = _ActionsAnchorParser() |
| 743 | + html_parser.feed(child.content) |
| 744 | + rendered_destinations.update(html_parser.destinations) |
| 745 | + continue |
| 746 | + if child.type != "link_open": |
| 747 | + continue |
| 748 | + target = child.attrGet("href") |
| 749 | + if not isinstance(target, str): |
| 750 | + continue |
| 751 | + destination = _CANONICAL_ACTIONS_DESTINATION.fullmatch(target) |
| 752 | + if destination is not None: |
| 753 | + rendered_destinations[(destination.group("run"), destination.group("attempt"))] += 1 |
| 754 | + |
| 755 | + if token.type == "html_block": |
| 756 | + html_parser = _ActionsAnchorParser() |
| 757 | + html_parser.feed(token.content) |
| 758 | + rendered_destinations.update(html_parser.destinations) |
| 759 | + |
| 760 | + for raw in _RAW_ACTIONS_DESTINATION.finditer(normalized): |
| 761 | + destination = _CANONICAL_ACTIONS_DESTINATION.fullmatch(raw.group("url")) |
| 762 | + if destination is not None: |
| 763 | + rendered_destinations[(destination.group("run"), destination.group("attempt"))] += 1 |
| 764 | + |
| 765 | + malformed = not converged or variant_paths[-1] != rendered_destinations |
779 | 766 | malformed = malformed or any( |
780 | | - _ACTIONS_PATH.search(target) is not None |
781 | | - and _CANONICAL_ACTIONS_URL.fullmatch(target) is None |
782 | | - for variant in variants |
783 | | - for target in _rendered_markdown_link_targets(variant) |
784 | | - ) |
785 | | - malformed = malformed or any( |
786 | | - count > direct_paths[identity] |
| 767 | + count > variant_paths[0][identity] |
787 | 768 | for paths in variant_paths[1:] |
788 | 769 | for identity, count in paths.items() |
789 | 770 | ) |
| 771 | + malformed = malformed or any( |
| 772 | + match.end() < len(normalized) and normalized[match.end()] not in " \t\r\n.,;:!?)]}>\"'" |
| 773 | + for match in _ACTIONS_PATH.finditer(normalized) |
| 774 | + ) |
790 | 775 |
|
791 | 776 | if malformed: |
792 | 777 | findings.append( |
793 | 778 | ( |
794 | 779 | line, |
795 | 780 | f"release-evidence block for {version} contains a non-canonical Actions URL; " |
796 | | - "use the exact repository /actions/runs/<positive-id> URL with an optional " |
| 781 | + "use the exact repository /actions/runs/<positive-id> URL as plain Markdown " |
| 782 | + "text, an autolink, or a link destination, with an optional " |
797 | 783 | "/attempts/<positive-id> suffix", |
798 | 784 | ) |
799 | 785 | ) |
|
0 commit comments